@useinsider/eslint-config 4.0.0-beta.1 → 4.0.0-beta.2

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/dist/cli.cjs CHANGED
@@ -309,20 +309,26 @@ function detectVueApiPattern(input) {
309
309
  //#region src/cli/dispatch.ts
310
310
  var yesFlags = /* @__PURE__ */ new Set(["--yes", "-y"]);
311
311
  var noInstallFlags = /* @__PURE__ */ new Set(["--no-install"]);
312
+ var fromLintFlags = /* @__PURE__ */ new Set(["--from-lint"]);
312
313
  function isYesFlag(arg) {
313
314
  return yesFlags.has(arg);
314
315
  }
315
316
  function isNoInstallFlag(arg) {
316
317
  return noInstallFlags.has(arg);
317
318
  }
319
+ function isFromLintFlag(arg) {
320
+ return fromLintFlags.has(arg);
321
+ }
318
322
  function parseMigrateArgs(rest) {
319
323
  const yes = rest.some(isYesFlag);
320
324
  const noInstall = rest.some(isNoInstallFlag);
321
- const positional = rest.filter((arg) => !isYesFlag(arg) && !isNoInstallFlag(arg));
325
+ const fromLint = rest.some(isFromLintFlag);
326
+ const positional = rest.filter((arg) => !isYesFlag(arg) && !isNoInstallFlag(arg) && !isFromLintFlag(arg));
322
327
  return {
323
328
  target: positional.length > 0 ? positional[positional.length - 1] : void 0,
324
329
  yes,
325
- noInstall
330
+ noInstall,
331
+ fromLint
326
332
  };
327
333
  }
328
334
  function parseScaffoldArgs(argv) {
@@ -333,8 +339,9 @@ function printUsage(stderr, unknown) {
333
339
  stderr("Usage:");
334
340
  stderr(" npx @useinsider/eslint-config [--yes|-y]");
335
341
  stderr(" Scaffold insider.lint.*, eslint.config.* and oxlint.config.*");
336
- stderr(" npx @useinsider/eslint-config migrate [path] [--yes|-y] [--no-install]");
342
+ stderr(" npx @useinsider/eslint-config migrate [path] [--yes|-y] [--no-install] [--from-lint]");
337
343
  stderr(" Split an existing useInsider() or .eslintrc* config into the three-file layout");
344
+ stderr(" --from-lint: started by a failing lint run — confirm first, exit non-zero if declined");
338
345
  }
339
346
  function isScaffoldArgv(argv) {
340
347
  return argv.every((arg) => isYesFlag(arg));
@@ -349,12 +356,13 @@ async function dispatch(argv, cwd, deps) {
349
356
  }
350
357
  const [subcommand, ...rest] = argv;
351
358
  if (subcommand === "migrate") {
352
- const { target, yes, noInstall } = parseMigrateArgs(rest);
359
+ const { target, yes, noInstall, fromLint } = parseMigrateArgs(rest);
353
360
  return deps.migrate({
354
361
  cwd,
355
362
  target,
356
363
  yes,
357
- noInstall
364
+ noInstall,
365
+ fromLint
358
366
  });
359
367
  }
360
368
  printUsage(deps.stderr, subcommand);
@@ -661,39 +669,66 @@ function noteGenericError(clack, summary, context, specs) {
661
669
  async function tryRetryInstall(clack, installer, deps, context) {
662
670
  try {
663
671
  await installer(deps);
672
+ return { installed: true };
664
673
  } catch (retryError) {
665
- noteError(clack, "Retry install failed after wipe: ", retryError instanceof Error ? retryError.message : String(retryError), context, deps);
674
+ const retryReason = retryError instanceof Error ? retryError.message : String(retryError);
675
+ noteError(clack, "Retry install failed after wipe: ", retryReason, context, deps);
676
+ return {
677
+ installed: false,
678
+ reason: retryReason
679
+ };
666
680
  }
667
681
  }
668
682
  async function tryGenericRetryInstall(clack, installer, deps, context) {
669
683
  try {
670
684
  await installer(deps);
685
+ return { installed: true };
671
686
  } catch (retryError) {
672
- noteGenericError(clack, buildGenericSummary(`Clean reinstall failed: ${retryError instanceof Error ? retryError.message : String(retryError)}`), context, deps);
687
+ const summary = buildGenericSummary(`Clean reinstall failed: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
688
+ noteGenericError(clack, summary, context, deps);
689
+ return {
690
+ installed: false,
691
+ reason: summary
692
+ };
673
693
  }
674
694
  }
675
695
  async function handlePeerDepFailure(clack, deps, autoConfirm, installer, reason, context) {
676
696
  if (!await confirmPeerDepWipe(clack, autoConfirm)) {
677
697
  noteError(clack, "Install failed: ", reason, context, deps);
678
- return;
698
+ return {
699
+ installed: false,
700
+ reason
701
+ };
679
702
  }
680
703
  performFullWipe(clack, context);
681
- await tryRetryInstall(clack, installer, deps, context);
704
+ return tryRetryInstall(clack, installer, deps, context);
682
705
  }
683
706
  async function handleGenericFailure(clack, deps, autoConfirm, installer, reason, context) {
684
707
  const summary = buildGenericSummary(reason);
685
708
  if (!await confirmCleanReinstall(clack, autoConfirm, summary)) {
686
709
  if (clack.note) clack.note(`${summary}\nSkipping clean reinstall. Please resolve the issue manually and re-run.`, "Error");
687
- return;
710
+ return {
711
+ installed: false,
712
+ reason: summary
713
+ };
688
714
  }
689
715
  performFullWipe(clack, context);
690
- await tryGenericRetryInstall(clack, installer, deps, context);
716
+ return tryGenericRetryInstall(clack, installer, deps, context);
691
717
  }
718
+ /**
719
+ * Installs the dependencies, offering one wipe-and-retry when the first attempt fails, and reports
720
+ * whether they ended up installed. Every failure path is already explained to the consumer through
721
+ * `clack`, so the outcome exists for the caller's exit code rather than for a second message.
722
+ * @example
723
+ * const outcome = await installWithRetry(options);
724
+ * if (!outcome.installed) { return 1; }
725
+ */
692
726
  async function installWithRetry(opts) {
693
727
  const installer = opts.installer ?? defaultInstaller;
694
728
  const fs = opts.fsOps ?? defaultWipeFs();
695
729
  try {
696
730
  await installer(opts.deps);
731
+ return { installed: true };
697
732
  } catch (error) {
698
733
  const reason = error instanceof Error ? error.message : String(error);
699
734
  const context = {
@@ -701,16 +736,14 @@ async function installWithRetry(opts) {
701
736
  fs,
702
737
  detected: detectLockfile(opts.cwd, fs.exists)
703
738
  };
704
- if (isPeerDepFailure(reason)) {
705
- await handlePeerDepFailure(opts.clack, opts.deps, opts.autoConfirm, installer, reason, context);
706
- return;
707
- }
708
- await handleGenericFailure(opts.clack, opts.deps, opts.autoConfirm, installer, reason, context);
739
+ if (isPeerDepFailure(reason)) return handlePeerDepFailure(opts.clack, opts.deps, opts.autoConfirm, installer, reason, context);
740
+ return handleGenericFailure(opts.clack, opts.deps, opts.autoConfirm, installer, reason, context);
709
741
  }
710
742
  }
711
743
  //#endregion
712
744
  //#region src/cli/lintLayout.ts
713
- var OXLINT_LINT_SCRIPT = "oxlint --disable-nested-config && eslint .";
745
+ var OXLINT_COMMAND = "oxlint --disable-nested-config";
746
+ var OXLINT_LINT_SCRIPT = `${OXLINT_COMMAND} && eslint .`;
714
747
  /**
715
748
  * Extension for the lint module and the oxlint config. oxlint only discovers `oxlint.config.ts` and
716
749
  * `oxlint.config.mts`, and Node warns on a bare `.ts` file inside a package that declares no
@@ -726,29 +759,45 @@ function resolveLayoutExtension(cwd) {
726
759
  return "mts";
727
760
  }
728
761
  }
729
- function findExistingEslintConfig(cwd, fallback) {
730
- return [
731
- "ts",
732
- "mts",
733
- "cts",
734
- "js",
735
- "mjs",
736
- "cjs"
737
- ].map((ext) => `eslint.config.${ext}`).find((filename) => (0, node_fs.existsSync)((0, node_path.join)(cwd, filename))) ?? fallback;
762
+ var ESLINT_CONFIG_EXTENSIONS = [
763
+ "ts",
764
+ "mts",
765
+ "cts",
766
+ "js",
767
+ "mjs",
768
+ "cjs"
769
+ ];
770
+ var TYPESCRIPT_CONFIG_EXTENSIONS = /* @__PURE__ */ new Set([
771
+ "cts",
772
+ "mts",
773
+ "ts"
774
+ ]);
775
+ function extensionOf(filename) {
776
+ return filename.slice(filename.lastIndexOf(".") + 1);
777
+ }
778
+ function existingEslintConfigs(cwd) {
779
+ return ESLINT_CONFIG_EXTENSIONS.map((extension) => `eslint.config.${extension}`).filter((filename) => (0, node_fs.existsSync)((0, node_path.join)(cwd, filename)));
738
780
  }
739
781
  /**
740
- * The three filenames `useInsider()` requires, resolved against a consumer directory. An existing
741
- * `eslint.config.*` keeps its name so a migration does not leave two flat configs behind.
782
+ * The three filenames `useInsider()` requires, resolved against a consumer directory. A JavaScript
783
+ * flat config is renamed to the TypeScript extension — it cannot import the lint module on every
784
+ * supported Node version — and every other `eslint.config.*` in the directory is reported through
785
+ * `replacedEslintConfigs` so the caller deletes it. ESLint resolves `.js`/`.mjs`/`.cjs` before the
786
+ * TypeScript extensions, so a leftover JavaScript config keeps winning over the one just written.
742
787
  * @example
743
- * resolveLintLayout('/repo'); // { lintModule: 'insider.lint.mts', eslintConfig: 'eslint.config.mjs', ... }
788
+ * resolveLintLayout('/repo');
789
+ * // { eslintConfig: 'eslint.config.ts', replacedEslintConfigs: ['eslint.config.js'], ... }
744
790
  */
745
- function resolveLintLayout(cwd, eslintConfigFallback = "eslint.config.mjs") {
791
+ function resolveLintLayout(cwd) {
746
792
  const extension = resolveLayoutExtension(cwd);
793
+ const existing = existingEslintConfigs(cwd);
794
+ const eslintConfig = existing.find((filename) => TYPESCRIPT_CONFIG_EXTENSIONS.has(extensionOf(filename))) ?? `eslint.config.${extension}`;
747
795
  return {
748
796
  extension,
749
797
  lintModule: `insider.lint.${extension}`,
750
798
  oxlintConfig: `oxlint.config.${extension}`,
751
- eslintConfig: findExistingEslintConfig(cwd, eslintConfigFallback)
799
+ eslintConfig,
800
+ replacedEslintConfigs: existing.filter((filename) => filename !== eslintConfig)
752
801
  };
753
802
  }
754
803
  /**
@@ -782,7 +831,7 @@ function renderOxlintConfigFile(lintModule) {
782
831
  * The two thin files that sit beside a lint module, ready to write.
783
832
  * @example
784
833
  * renderLintLayoutFiles(resolveLintLayout('/repo')).map(file => file.filename);
785
- * // ['eslint.config.mjs', 'oxlint.config.mts']
834
+ * // ['eslint.config.mts', 'oxlint.config.mts']
786
835
  */
787
836
  function renderLintLayoutFiles(layout) {
788
837
  return [{
@@ -801,12 +850,111 @@ function detectIndent$1(raw) {
801
850
  const width = rest.length - rest.trimStart().length;
802
851
  return width > 0 ? width : DEFAULT_INDENT;
803
852
  }
853
+ var SEGMENT_SEPARATOR = /&&|\|\||[;|]/u;
854
+ var ASSIGNMENT = /^[A-Za-z_]\w*=/u;
855
+ var WRAPPER_COMMANDS = /* @__PURE__ */ new Set([
856
+ "bunx",
857
+ "cross-env",
858
+ "dotenv",
859
+ "exec",
860
+ "npx",
861
+ "pnpm",
862
+ "run",
863
+ "yarn"
864
+ ]);
865
+ function baseName(token) {
866
+ return token.slice(token.lastIndexOf("/") + 1).replace(/\.[mc]?js$/u, "");
867
+ }
868
+ function segmentsOf(command) {
869
+ return command.split(SEGMENT_SEPARATOR).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
870
+ }
871
+ function tokensOf(segment) {
872
+ return segment.split(/\s+/u).filter((token) => token.length > 0 && !ASSIGNMENT.test(token));
873
+ }
874
+ function commandWordOf(segment) {
875
+ return tokensOf(segment).map(baseName).find((word) => !word.startsWith("-") && !WRAPPER_COMMANDS.has(word)) ?? null;
876
+ }
877
+ /**
878
+ * Whether a package script really invokes the `eslint` binary, as opposed to delegating to another
879
+ * script that does. `npm run lint:es` is not an ESLint invocation; `NODE_OPTIONS=… eslint .` is.
880
+ * @example
881
+ * invokesBinary('npm run lint:es-ci -- --cache', 'eslint'); // false
882
+ */
883
+ function invokesBinary(command, binary) {
884
+ return segmentsOf(command).some((segment) => commandWordOf(segment) === binary);
885
+ }
886
+ function oxlintPrefixFor(command) {
887
+ return tokensOf(command).includes("--fix") ? `${OXLINT_COMMAND} --fix` : OXLINT_COMMAND;
888
+ }
889
+ var CANONICAL_LINT_SCRIPT = "lint";
890
+ function referencedScripts(command, names) {
891
+ return tokensOf(command).map((token) => token.replace(/^["']|["']$/gu, "").replace(/^npm:/u, "")).filter((token) => names.has(token));
892
+ }
893
+ function collectReachable(scripts, names, script, seen) {
894
+ if (seen.has(script)) return;
895
+ seen.add(script);
896
+ referencedScripts(scripts[script] ?? "", names).forEach((next) => {
897
+ collectReachable(scripts, names, next, seen);
898
+ });
899
+ }
900
+ function reachableFromLint(scripts) {
901
+ const names = new Set(Object.keys(scripts));
902
+ const seen = /* @__PURE__ */ new Set();
903
+ if (names.has(CANONICAL_LINT_SCRIPT)) collectReachable(scripts, names, CANONICAL_LINT_SCRIPT, seen);
904
+ return seen;
905
+ }
906
+ function fixingRank(candidate) {
907
+ return tokensOf(candidate.previous).includes("--fix") ? 1 : 0;
908
+ }
909
+ function compareCandidates(left, right) {
910
+ return fixingRank(left) - fixingRank(right) || left.script.length - right.script.length || left.script.localeCompare(right.script);
911
+ }
912
+ function selectTarget(scripts, candidates) {
913
+ if (candidates.length === 0) return null;
914
+ const reachable = reachableFromLint(scripts);
915
+ const preferred = candidates.filter((candidate) => reachable.has(candidate.script));
916
+ return [...preferred.length > 0 ? preferred : candidates].sort(compareCandidates)[0];
917
+ }
918
+ function planChanges(scripts) {
919
+ const alreadyRunsOxlint = [];
920
+ const candidates = [];
921
+ Object.entries(scripts).forEach(([script, previous]) => {
922
+ if (!invokesBinary(previous, "eslint")) return;
923
+ if (invokesBinary(previous, "oxlint")) {
924
+ alreadyRunsOxlint.push(script);
925
+ return;
926
+ }
927
+ candidates.push({
928
+ script,
929
+ previous
930
+ });
931
+ });
932
+ const target = selectTarget(scripts, candidates);
933
+ if (!target) return {
934
+ changes: [],
935
+ alreadyRunsOxlint,
936
+ skipped: []
937
+ };
938
+ return {
939
+ changes: [{
940
+ script: target.script,
941
+ previous: target.previous,
942
+ next: `${oxlintPrefixFor(target.previous)} && ${target.previous}`
943
+ }],
944
+ alreadyRunsOxlint,
945
+ skipped: candidates.filter((candidate) => candidate.script !== target.script)
946
+ };
947
+ }
804
948
  /**
805
- * Rewrites the consumer's `lint` script so both linters run. Returns the manifest untouched when it
806
- * already runs oxlint, so re-running the CLI is idempotent.
949
+ * Adds oxlint beside the one script that actually runs ESLint, by prepending to it rather than
950
+ * replacing it — so a composed `lint` such as `run-p lint:type lint:es lint:style` keeps its
951
+ * type-check and stylelint passes and only its `lint:es-ci` leaf is rewritten. Sibling entry points
952
+ * that also run ESLint (`lint:fix`, `lint:ci`) are reported through `skipped` rather than rewritten,
953
+ * so oxlint runs once per lint pass. Never rewrites a script it did not author: when nothing invokes
954
+ * `eslint` and a `lint` script already exists, the manifest is left alone and the conflict reported.
807
955
  * @example
808
- * setLintScript('{"scripts":{"lint":"eslint ."}}').manifest;
809
- * // '…"lint": "oxlint --disable-nested-config && eslint ."…'
956
+ * setLintScript('{"scripts":{"lint":"eslint ."}}').changes;
957
+ * // [{ script: 'lint', previous: 'eslint .', next: 'oxlint --disable-nested-config && eslint .' }]
810
958
  */
811
959
  function setLintScript(rawManifest) {
812
960
  let parsed;
@@ -815,25 +963,77 @@ function setLintScript(rawManifest) {
815
963
  } catch {
816
964
  return {
817
965
  manifest: rawManifest,
818
- changed: false
966
+ changed: false,
967
+ changes: [],
968
+ alreadyRunsOxlint: [],
969
+ skipped: []
970
+ };
971
+ }
972
+ const scripts = parsed.scripts ?? {};
973
+ const { changes, alreadyRunsOxlint, skipped } = planChanges(scripts);
974
+ const existingLint = scripts.lint;
975
+ if (changes.length === 0 && alreadyRunsOxlint.length === 0) {
976
+ if (existingLint !== void 0) return {
977
+ manifest: rawManifest,
978
+ changed: false,
979
+ changes: [],
980
+ alreadyRunsOxlint: [],
981
+ skipped: [],
982
+ unresolved: {
983
+ reason: "lint-script-runs-neither",
984
+ script: "lint",
985
+ previous: existingLint
986
+ }
819
987
  };
988
+ changes.push({
989
+ script: "lint",
990
+ previous: "",
991
+ next: OXLINT_LINT_SCRIPT
992
+ });
820
993
  }
821
- const previous = parsed.scripts?.lint;
822
- if (previous?.includes("oxlint")) return {
994
+ if (changes.length === 0) return {
823
995
  manifest: rawManifest,
824
996
  changed: false,
825
- previous
826
- };
827
- parsed.scripts = {
828
- ...parsed.scripts,
829
- lint: OXLINT_LINT_SCRIPT
997
+ changes: [],
998
+ alreadyRunsOxlint,
999
+ skipped
830
1000
  };
1001
+ parsed.scripts = { ...scripts };
1002
+ changes.forEach((change) => {
1003
+ parsed.scripts[change.script] = change.next;
1004
+ });
831
1005
  return {
832
1006
  manifest: `${JSON.stringify(parsed, null, detectIndent$1(rawManifest))}\n`,
833
1007
  changed: true,
834
- previous
1008
+ changes,
1009
+ alreadyRunsOxlint,
1010
+ skipped
835
1011
  };
836
1012
  }
1013
+ /**
1014
+ * The scripts that still run ESLint alone, for the CLI to print. Each line carries the oxlint prefix
1015
+ * that script would need, so a consumer who wants oxlint on a second entry point can paste it.
1016
+ * @example
1017
+ * formatSkippedLintScripts([{ script: 'lint:fix', previous: 'eslint . --fix' }]);
1018
+ * // 'lint:fix\n prefix with: oxlint --disable-nested-config --fix && '
1019
+ */
1020
+ function formatSkippedLintScripts(skipped) {
1021
+ return skipped.map((candidate) => `${candidate.script}\n prefix with: ${oxlintPrefixFor(candidate.previous)} && `).join("\n\n");
1022
+ }
1023
+ /**
1024
+ * One line per rewritten script, for the CLI to print. The previous command survives verbatim inside
1025
+ * the new one, so the report doubles as the recovery instructions.
1026
+ * @example
1027
+ * formatLintScriptChanges([{ script: 'lint:es-ci', previous: 'eslint .', next: 'oxlint && eslint .' }]);
1028
+ * // 'lint:es-ci\n before: eslint .\n after: oxlint && eslint .'
1029
+ */
1030
+ function formatLintScriptChanges(changes) {
1031
+ return changes.map((change) => [
1032
+ change.script,
1033
+ ` before: ${change.previous === "" ? "(no such script)" : change.previous}`,
1034
+ ` after: ${change.next}`
1035
+ ].join("\n")).join("\n\n");
1036
+ }
837
1037
  //#endregion
838
1038
  //#region src/cli/quote.ts
839
1039
  function quote(value) {
@@ -1753,31 +1953,89 @@ var TS_EXTENSIONS_SNIPPET = [
1753
1953
  "}"
1754
1954
  ].join("\n");
1755
1955
  var DEFAULT_INDENT_UNIT = " ";
1756
- function readEffectiveFlag(path, raw) {
1956
+ function acceptsTsExtensionsFlag(options) {
1957
+ return options.noEmit === true || options.emitDeclarationOnly === true || options.rewriteRelativeImportExtensions === true;
1958
+ }
1959
+ function parseTsconfig(path, raw) {
1757
1960
  const { config } = typescript.parseConfigFileTextToJson(path, raw);
1758
- if (!config) return false;
1759
- return typescript.parseJsonConfigFileContent(config, typescript.sys, (0, node_path.dirname)(path), void 0, path).options.allowImportingTsExtensions === true;
1961
+ if (!config) return null;
1962
+ const parsed = typescript.parseJsonConfigFileContent(config, typescript.sys, (0, node_path.dirname)(path), void 0, path);
1963
+ return {
1964
+ enabled: parsed.options.allowImportingTsExtensions === true,
1965
+ acceptsFlag: acceptsTsExtensionsFlag(parsed.options),
1966
+ fileNames: parsed.fileNames,
1967
+ references: (parsed.projectReferences ?? []).map((reference) => reference.path)
1968
+ };
1760
1969
  }
1761
- /**
1762
- * The consumer's root `tsconfig.json` plus whether `allowImportingTsExtensions` already resolves to
1763
- * `true` — including when it arrives through `extends`, which is followed the same way `tsc` does.
1764
- * @example
1765
- * readTsconfigState('/repo')?.enabled; // true when /repo/tsconfig.json extends a base that sets it
1766
- */
1767
- function readTsconfigState(cwd) {
1768
- const path = (0, node_path.join)(cwd, "tsconfig.json");
1970
+ function readTsconfigAt(path, governedFiles) {
1769
1971
  if (!(0, node_fs.existsSync)(path)) return null;
1770
1972
  try {
1771
1973
  const raw = (0, node_fs.readFileSync)(path, "utf8");
1974
+ const parsed = parseTsconfig(path, raw);
1975
+ if (!parsed) return {
1976
+ state: {
1977
+ path,
1978
+ raw,
1979
+ enabled: false,
1980
+ governs: false,
1981
+ acceptsFlag: false
1982
+ },
1983
+ references: []
1984
+ };
1985
+ const owned = new Set(parsed.fileNames.map((name) => typescript.sys.resolvePath(name)));
1986
+ const governs = governedFiles.some((file) => owned.has(typescript.sys.resolvePath(file)));
1772
1987
  return {
1773
- path,
1774
- raw,
1775
- enabled: readEffectiveFlag(path, raw)
1988
+ state: {
1989
+ path,
1990
+ raw,
1991
+ enabled: parsed.enabled,
1992
+ governs,
1993
+ acceptsFlag: parsed.acceptsFlag
1994
+ },
1995
+ references: parsed.references
1776
1996
  };
1777
1997
  } catch {
1778
1998
  return null;
1779
1999
  }
1780
2000
  }
2001
+ function resolveReferencePath(reference) {
2002
+ if (!(0, node_fs.existsSync)(reference)) return reference;
2003
+ return typescript.sys.directoryExists(reference) ? (0, node_path.join)(reference, "tsconfig.json") : reference;
2004
+ }
2005
+ /**
2006
+ * Every tsconfig in the consumer's solution — the root plus each project reference — annotated with
2007
+ * whether it actually type-checks the generated lint files. A solution-style root has an empty file
2008
+ * set, so the project that governs `insider.lint.ts` is usually a referenced `tsconfig.node.json`,
2009
+ * and that is the one `tsc` reports TS5097 from.
2010
+ * @example
2011
+ * collectTsconfigStates('/repo', ['/repo/insider.lint.ts']).filter(state => state.governs);
2012
+ * // [{ path: '/repo/tsconfig.node.json', enabled: true, governs: true, ... }]
2013
+ */
2014
+ function collectTsconfigStates(cwd, governedFiles = []) {
2015
+ const root = readTsconfigAt((0, node_path.join)(cwd, "tsconfig.json"), governedFiles);
2016
+ if (!root) return [];
2017
+ const seen = /* @__PURE__ */ new Set([root.state.path]);
2018
+ const states = [root.state];
2019
+ root.references.forEach((reference) => {
2020
+ const path = resolveReferencePath(reference);
2021
+ if (seen.has(path)) return;
2022
+ seen.add(path);
2023
+ const referenced = readTsconfigAt(path, governedFiles);
2024
+ if (referenced) states.push(referenced.state);
2025
+ });
2026
+ return states;
2027
+ }
2028
+ /**
2029
+ * The tsconfigs that must gain `allowImportingTsExtensions`: the ones that type-check the generated
2030
+ * files and do not already resolve the flag to `true`. Empty when an applicable config already sets
2031
+ * it, and empty when no config type-checks the generated files at all — a tsconfig that never sees
2032
+ * them cannot raise TS5097, so editing it would only add a flag it has no use for.
2033
+ * @example
2034
+ * tsconfigsNeedingFlag('/repo', ['/repo/insider.lint.ts']).map(state => state.path); // []
2035
+ */
2036
+ function tsconfigsNeedingFlag(cwd, governedFiles) {
2037
+ return collectTsconfigStates(cwd, governedFiles).filter((state) => state.governs && !state.enabled);
2038
+ }
1781
2039
  function rootObject(source) {
1782
2040
  const [statement] = source.statements;
1783
2041
  const expression = statement?.expression;
@@ -1861,6 +2119,10 @@ function noteManualStep(clack, lintModule) {
1861
2119
  if (!clack.note) return;
1862
2120
  clack.note(`\`eslint.config.*\` imports \`./${lintModule}\` by its real extension, which \`tsc\` rejects with TS5097 unless \`${TS_EXTENSIONS_FLAG}\` is on. Add this to your tsconfig.json:\n\n${TS_EXTENSIONS_SNIPPET}`, "tsconfig.json not updated");
1863
2121
  }
2122
+ function noteEmitConflict(clack, path, lintModule) {
2123
+ if (!clack.note) return;
2124
+ clack.note(`${path} type-checks \`./${lintModule}\`, so \`tsc\` needs \`${TS_EXTENSIONS_FLAG}\` there — but that project emits, and TypeScript rejects the pair with TS5096. Set \`noEmit\`, \`emitDeclarationOnly\` or \`rewriteRelativeImportExtensions\` on it first, then add:\n\n${TS_EXTENSIONS_SNIPPET}`, "tsconfig.json not updated");
2125
+ }
1864
2126
  async function confirmFlag(clack, path) {
1865
2127
  const result = await clack.confirm({
1866
2128
  message: `Set \`${TS_EXTENSIONS_FLAG}: true\` in ${path}? Without it \`tsc\` fails with TS5097 on the generated config files.`,
@@ -1868,16 +2130,14 @@ async function confirmFlag(clack, path) {
1868
2130
  });
1869
2131
  return !clack.isCancel(result) && result === true;
1870
2132
  }
1871
- /**
1872
- * Offers to set `allowImportingTsExtensions` on the consumer's `tsconfig.json`, and prints the exact
1873
- * snippet when the offer is declined. A no-op when there is no `tsconfig.json` or the flag already
1874
- * resolves to `true` through the `extends` chain.
1875
- * @example
1876
- * await applyTsExtensionsFlag(clack, { cwd: '/repo', lintModule: 'insider.lint.ts' });
1877
- */
1878
- async function applyTsExtensionsFlag(clack, input) {
1879
- const state = readTsconfigState(input.cwd);
1880
- if (!state || state.enabled) return;
2133
+ function governedFilesOf(input) {
2134
+ return [input.lintModule, input.eslintConfig].filter((name) => Boolean(name)).map((name) => (0, node_path.join)(input.cwd, name));
2135
+ }
2136
+ async function applyToOne(clack, state, input) {
2137
+ if (!state.acceptsFlag) {
2138
+ noteEmitConflict(clack, state.path, input.lintModule);
2139
+ return;
2140
+ }
1881
2141
  if (!(input.autoConfirm || await confirmFlag(clack, state.path))) {
1882
2142
  noteManualStep(clack, input.lintModule);
1883
2143
  return;
@@ -1890,6 +2150,22 @@ async function applyTsExtensionsFlag(clack, input) {
1890
2150
  (0, node_fs.writeFileSync)(state.path, result.source, "utf8");
1891
2151
  if (clack.note) clack.note(`Set \`${TS_EXTENSIONS_FLAG}: true\` in ${state.path}.`, "Updated tsconfig.json");
1892
2152
  }
2153
+ /**
2154
+ * Offers to set `allowImportingTsExtensions` on every tsconfig that actually type-checks the
2155
+ * generated lint files, and prints the exact snippet when an offer is declined or when the project
2156
+ * emits — in which case writing the flag would trade TS5097 for TS5096. A no-op when there is no
2157
+ * `tsconfig.json`, when no config governs the generated files, or when an applicable config already
2158
+ * resolves the flag to `true` — through its own `compilerOptions`, through `extends`, or by being a
2159
+ * project reference that already sets it.
2160
+ * @example
2161
+ * await applyTsExtensionsFlag(clack, { cwd: '/repo', lintModule: 'insider.lint.ts' });
2162
+ */
2163
+ async function applyTsExtensionsFlag(clack, input) {
2164
+ await tsconfigsNeedingFlag(input.cwd, governedFilesOf(input)).reduce(async (previous, state) => {
2165
+ await previous;
2166
+ return applyToOne(clack, state, input);
2167
+ }, Promise.resolve());
2168
+ }
1893
2169
  //#endregion
1894
2170
  //#region src/cli/migrate/disableDirectives.ts
1895
2171
  var DIRECTIVE_HEAD = /^\s*eslint-disable(-next-line|-line)?(?![\w-])/u;
@@ -2050,31 +2326,63 @@ function rewriteDirectives(source, fileExtension) {
2050
2326
  function isLintedFile(filePath) {
2051
2327
  return LINTED_EXTENSIONS.includes((0, node_path.extname)(filePath));
2052
2328
  }
2053
- function collectFiles(directory, accumulator) {
2054
- (0, node_fs.readdirSync)(directory).forEach((entry) => {
2055
- if (SKIPPED_DIRECTORIES.has(entry)) return;
2056
- const full = (0, node_path.join)(directory, entry);
2057
- if ((0, node_fs.statSync)(full).isDirectory()) {
2058
- collectFiles(full, accumulator);
2329
+ function readEntries(directory) {
2330
+ try {
2331
+ return (0, node_fs.readdirSync)(directory, { withFileTypes: true });
2332
+ } catch {
2333
+ return [];
2334
+ }
2335
+ }
2336
+ function realPathOf(path) {
2337
+ try {
2338
+ return (0, node_fs.realpathSync)(path);
2339
+ } catch {
2340
+ return null;
2341
+ }
2342
+ }
2343
+ function collectFiles(directory, accumulator, visited) {
2344
+ const real = realPathOf(directory);
2345
+ if (real === null || visited.has(real)) return;
2346
+ visited.add(real);
2347
+ readEntries(directory).forEach((entry) => {
2348
+ if (SKIPPED_DIRECTORIES.has(entry.name)) return;
2349
+ const full = (0, node_path.join)(directory, entry.name);
2350
+ if (entry.isDirectory()) {
2351
+ collectFiles(full, accumulator, visited);
2059
2352
  return;
2060
2353
  }
2061
- if (isLintedFile(full)) accumulator.push(full);
2354
+ if (entry.isFile() && isLintedFile(full)) accumulator.push(full);
2062
2355
  });
2063
2356
  }
2064
2357
  /**
2065
- * Applies {@link rewriteDirectives} across a consumer tree, skipping build output and dependencies.
2358
+ * Every file whose `eslint-disable` comments would be re-pointed, and the rewritten source, without
2359
+ * touching disk. The directive rewrite is the largest diff a migration produces, so the count is
2360
+ * reported — and confirmed — before anything is written.
2066
2361
  * @example
2067
- * rewriteDisableDirectives('/repo').rewritten; // ['/repo/src/logger.ts']
2362
+ * previewDisableDirectives('/repo').length; // 131
2068
2363
  */
2069
- function rewriteDisableDirectives(cwd) {
2364
+ function previewDisableDirectives(cwd) {
2070
2365
  const files = [];
2071
- collectFiles(cwd, files);
2072
- return { rewritten: files.filter((filePath) => {
2366
+ collectFiles(cwd, files, /* @__PURE__ */ new Set());
2367
+ return files.reduce((candidates, filePath) => {
2073
2368
  const result = rewriteDirectives((0, node_fs.readFileSync)(filePath, "utf8"), (0, node_path.extname)(filePath));
2074
- if (!result.changed) return false;
2075
- (0, node_fs.writeFileSync)(filePath, result.source, "utf8");
2076
- return true;
2077
- }) };
2369
+ if (!result.changed) return candidates;
2370
+ return [...candidates, {
2371
+ path: filePath,
2372
+ source: result.source
2373
+ }];
2374
+ }, []);
2375
+ }
2376
+ /**
2377
+ * Applies {@link rewriteDirectives} across a consumer tree, skipping build output and dependencies.
2378
+ * @example
2379
+ * rewriteDisableDirectives('/repo').rewritten; // ['/repo/src/logger.ts']
2380
+ */
2381
+ function rewriteDisableDirectives(cwd, candidates = previewDisableDirectives(cwd)) {
2382
+ candidates.forEach((candidate) => {
2383
+ (0, node_fs.writeFileSync)(candidate.path, candidate.source, "utf8");
2384
+ });
2385
+ return { rewritten: candidates.map((candidate) => candidate.path) };
2078
2386
  }
2079
2387
  //#endregion
2080
2388
  //#region src/cli/migrate/stringEntry.ts
@@ -4383,53 +4691,56 @@ function formatWarnings(warnings) {
4383
4691
  }
4384
4692
  //#endregion
4385
4693
  //#region src/cli/migrate/writer.ts
4386
- function writeLayout(eslintConfigPath, lintModuleSource) {
4387
- const directory = (0, node_path.dirname)(eslintConfigPath);
4388
- const layout = resolveLintLayout(directory, (0, node_path.basename)(eslintConfigPath));
4694
+ function writeLayout(directory, lintModuleSource) {
4695
+ const layout = resolveLintLayout(directory);
4389
4696
  const lintModulePath = (0, node_path.join)(directory, layout.lintModule);
4390
4697
  (0, node_fs.writeFileSync)(lintModulePath, lintModuleSource, "utf8");
4391
4698
  const writtenPaths = [lintModulePath];
4392
- renderLintLayoutFiles({
4393
- ...layout,
4394
- eslintConfig: (0, node_path.basename)(eslintConfigPath)
4395
- }).forEach((file) => {
4699
+ renderLintLayoutFiles(layout).forEach((file) => {
4396
4700
  const path = (0, node_path.join)(directory, file.filename);
4397
4701
  (0, node_fs.writeFileSync)(path, file.source, "utf8");
4398
4702
  writtenPaths.push(path);
4399
4703
  });
4400
4704
  return {
4705
+ eslintConfigPath: (0, node_path.join)(directory, layout.eslintConfig),
4401
4706
  lintModulePath,
4402
- writtenPaths
4707
+ writtenPaths,
4708
+ replacedEslintConfigPaths: layout.replacedEslintConfigs.map((name) => (0, node_path.join)(directory, name))
4403
4709
  };
4404
4710
  }
4711
+ function withoutDuplicates(paths, keep) {
4712
+ return [...new Set(paths.filter((path) => path !== keep))];
4713
+ }
4405
4714
  /**
4406
4715
  * Writes the three-file layout beside an existing flat config: the rendered `useInsider()` call
4407
- * becomes `insider.lint.*`, and `eslint.config.*` / `oxlint.config.*` become thin re-exports.
4716
+ * becomes `insider.lint.*`, and `eslint.config.*` / `oxlint.config.*` become thin re-exports. A
4717
+ * JavaScript-extension source config is superseded by the TypeScript one and nominated for deletion.
4408
4718
  * @example
4409
- * writeMigration('/repo/eslint.config.mjs', source).writtenPaths.length; // 3
4719
+ * writeMigration('/repo/eslint.config.js', source).deletedSourceCandidates; // ['/repo/eslint.config.js']
4410
4720
  */
4411
4721
  function writeMigration(targetPath, newSource) {
4412
- const { lintModulePath, writtenPaths } = writeLayout(targetPath, newSource);
4722
+ const result = writeLayout((0, node_path.dirname)(targetPath), newSource);
4413
4723
  return {
4414
- targetPath,
4415
- lintModulePath,
4416
- writtenPaths
4724
+ targetPath: result.eslintConfigPath,
4725
+ lintModulePath: result.lintModulePath,
4726
+ writtenPaths: result.writtenPaths,
4727
+ deletedSourceCandidates: withoutDuplicates([...result.replacedEslintConfigPaths, targetPath], result.eslintConfigPath)
4417
4728
  };
4418
4729
  }
4419
4730
  /**
4420
4731
  * Same as {@link writeMigration}, but for a legacy `.eslintrc*` source that has to be replaced by a
4421
- * differently-named flat config. Nominates the old file for deletion when the name changed.
4732
+ * differently-named flat config.
4422
4733
  * @example
4423
- * writeMigrationLegacy('/repo/.eslintrc.cjs', '/repo/eslint.config.mjs', source).deletedSourceCandidate;
4424
- * // '/repo/.eslintrc.cjs'
4734
+ * writeMigrationLegacy('/repo/.eslintrc.cjs', '/repo/eslint.config.mts', src).deletedSourceCandidates;
4735
+ * // ['/repo/.eslintrc.cjs']
4425
4736
  */
4426
4737
  function writeMigrationLegacy(sourcePath, writePath, newSource) {
4427
- const { lintModulePath, writtenPaths } = writeLayout(writePath, newSource);
4738
+ const result = writeLayout((0, node_path.dirname)(writePath), newSource);
4428
4739
  return {
4429
- targetPath: writePath,
4430
- lintModulePath,
4431
- writtenPaths,
4432
- deletedSourceCandidate: (0, node_path.basename)(sourcePath) === (0, node_path.basename)(writePath) ? void 0 : sourcePath
4740
+ targetPath: result.eslintConfigPath,
4741
+ lintModulePath: result.lintModulePath,
4742
+ writtenPaths: result.writtenPaths,
4743
+ deletedSourceCandidates: withoutDuplicates([...result.replacedEslintConfigPaths, sourcePath], result.eslintConfigPath)
4433
4744
  };
4434
4745
  }
4435
4746
  //#endregion
@@ -4504,26 +4815,14 @@ function summary(plan, format) {
4504
4815
  function planFromSource(source, filePath, format) {
4505
4816
  if (format === "flat") {
4506
4817
  const parseResult = parseV1Config(source, filePath);
4507
- if (!isV1Shape(parseResult)) return null;
4508
- const plan = transformToV2(parseResult);
4509
- return {
4510
- plan,
4511
- rendered: renderV2(plan)
4512
- };
4818
+ return isV1Shape(parseResult) ? transformToV2(parseResult) : null;
4513
4819
  }
4514
4820
  const parseResult = parseEslintrc(source, filePath);
4515
- if (!parseResult.found) return null;
4516
- const plan = transformEslintrcToV2(parseResult);
4517
- return {
4518
- plan,
4519
- rendered: renderV2(plan)
4520
- };
4821
+ return parseResult.found ? transformEslintrcToV2(parseResult) : null;
4521
4822
  }
4522
- function targetWritePath(cwd, sourcePath, format) {
4523
- if (format !== "eslintrc") return sourcePath;
4524
- const base = (0, node_path.basename)(sourcePath);
4525
- const dir = sourcePath.slice(0, sourcePath.length - base.length);
4526
- return (0, node_path.join)(dir.length > 0 ? dir : cwd, "eslint.config.mjs");
4823
+ function targetWritePath(cwd, sourcePath) {
4824
+ const directory = (0, node_path.dirname)(sourcePath) || cwd;
4825
+ return (0, node_path.join)(directory, resolveLintLayout(directory).eslintConfig);
4527
4826
  }
4528
4827
  function parseEslintIgnoreContents(raw) {
4529
4828
  return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
@@ -4557,6 +4856,17 @@ function describeDepChanges(depChanges) {
4557
4856
  lines.push(`- Install (${depChanges.install.specs.length}): ${depChanges.install.specs.join(", ")}`);
4558
4857
  return lines.join("\n");
4559
4858
  }
4859
+ async function confirmGuidedStart(clack, sourcePath) {
4860
+ const result = await clack.confirm({
4861
+ message: `${(0, node_path.basename)(sourcePath)} still uses the v3 single-file layout. Migrate it now?`,
4862
+ initialValue: true
4863
+ });
4864
+ return !clack.isCancel(result) && result === true;
4865
+ }
4866
+ function noteConfigRename(clack, sourcePath, writePath) {
4867
+ if (!clack.note || (0, node_path.basename)(sourcePath) === (0, node_path.basename)(writePath)) return;
4868
+ clack.note(`${(0, node_path.basename)(sourcePath)} -> ${(0, node_path.basename)(writePath)}\n\nESLint loads a JavaScript config through Node, so importing the TypeScript lint module from one needs unflagged type stripping (Node >= 22.18) and fails on Node 20. A TypeScript config goes through jiti and loads on every supported version.`, "Renaming the flat config");
4869
+ }
4560
4870
  async function confirmWrite(clack, writePath, initial = true) {
4561
4871
  const message = `Write migrated config to ${writePath}?`;
4562
4872
  const result = await clack.confirm({
@@ -4600,10 +4910,17 @@ function emitSummary(clack, plan, format) {
4600
4910
  function writeAndAnnounce(clack, targetPath, writePath, rendered, format) {
4601
4911
  const result = format === "eslintrc" ? writeMigrationLegacy(targetPath, writePath, rendered) : writeMigration(targetPath, rendered);
4602
4912
  clack.outro(`Wrote ${result.writtenPaths.join(", ")}`);
4603
- return { deletedSourceCandidate: result.deletedSourceCandidate };
4913
+ return { deletedSourceCandidates: result.deletedSourceCandidates };
4914
+ }
4915
+ function supersededFlatConfigs(sourcePath, writePath) {
4916
+ const directory = (0, node_path.dirname)(writePath);
4917
+ const { replacedEslintConfigs } = resolveLintLayout(directory);
4918
+ const candidates = [...replacedEslintConfigs.map((filename) => (0, node_path.join)(directory, filename)), writePath];
4919
+ return [...new Set(candidates.filter((path) => path !== sourcePath && (0, node_fs.existsSync)(path)))];
4604
4920
  }
4605
- async function confirmOverwriteExistingOutput(clack, writePath, sourcePath, initial = false) {
4606
- const message = `An \`${(0, node_path.basename)(writePath)}\` already exists. Overwrite + delete \`${(0, node_path.basename)(sourcePath)}\`?`;
4921
+ async function confirmOverwriteExistingOutput(clack, superseded, sourcePath, initial = false) {
4922
+ const names = superseded.map((path) => (0, node_path.basename)(path));
4923
+ const message = `${names.length === 1 ? `An \`${names[0]}\` already exists` : `\`${names.join("`, `")}\` already exist`}. Overwrite + delete \`${(0, node_path.basename)(sourcePath)}\`?`;
4607
4924
  const result = await clack.confirm({
4608
4925
  message,
4609
4926
  initialValue: initial
@@ -4639,10 +4956,9 @@ function resolveSource(input, clack) {
4639
4956
  };
4640
4957
  }
4641
4958
  function prepareMigration(resolved, input) {
4642
- const planResult = planFromSource(resolved.source, resolved.targetPath, resolved.format);
4643
- if (!planResult) return null;
4644
- const { plan } = planResult;
4645
- const writePath = targetWritePath(input.cwd, resolved.targetPath, resolved.format);
4959
+ const plan = planFromSource(resolved.source, resolved.targetPath, resolved.format);
4960
+ if (!plan) return null;
4961
+ const writePath = targetWritePath(input.cwd, resolved.targetPath);
4646
4962
  const eslintIgnore = findEslintIgnore(resolved.targetPath);
4647
4963
  if (eslintIgnore && eslintIgnore.patterns.length > 0) mergeIgnorePatterns(plan, eslintIgnore.patterns);
4648
4964
  return {
@@ -4653,8 +4969,9 @@ function prepareMigration(resolved, input) {
4653
4969
  };
4654
4970
  }
4655
4971
  async function runConfirmations(clack, input, targetPath, writePath, previous) {
4656
- if (writePath !== targetPath && (0, node_fs.existsSync)(writePath)) {
4657
- if (!await confirmOverwriteExistingOutput(clack, writePath, targetPath, previous?.overwriteExisting ?? false)) {
4972
+ const superseded = supersededFlatConfigs(targetPath, writePath);
4973
+ if (superseded.length > 0) {
4974
+ if (!await confirmOverwriteExistingOutput(clack, superseded, targetPath, previous?.overwriteExisting ?? false)) {
4658
4975
  clack.outro("Aborted: existing output kept.");
4659
4976
  return false;
4660
4977
  }
@@ -4712,7 +5029,7 @@ function resolveInstallCwd(depSummary, input) {
4712
5029
  return input.cwd;
4713
5030
  }
4714
5031
  async function runInstaller(clack, depSummary, input) {
4715
- await installWithRetry({
5032
+ return installWithRetry({
4716
5033
  deps: depSummary.install.specs,
4717
5034
  cwd: resolveInstallCwd(depSummary, input),
4718
5035
  autoConfirm: Boolean(input.yes),
@@ -4724,42 +5041,73 @@ async function runInstaller(clack, depSummary, input) {
4724
5041
  async function applyDepChanges(clack, depSummary, input) {
4725
5042
  if (!depSummary.packageJsonPath) {
4726
5043
  if (clack.note) clack.note("No package.json found alongside the migrated config — skipping dependency updates.", "Notice");
4727
- return;
5044
+ return true;
4728
5045
  }
4729
5046
  persistPrunedManifest(depSummary);
4730
5047
  if (input.noInstall) {
4731
5048
  emitInstallNextSteps(clack, depSummary, input.cwd);
4732
- return;
5049
+ return true;
4733
5050
  }
4734
5051
  if (!input.yes) {
4735
5052
  if (!await confirmInstall(clack, depSummary)) {
4736
5053
  emitInstallNextSteps(clack, depSummary, input.cwd);
4737
- return;
5054
+ return true;
4738
5055
  }
4739
5056
  }
4740
5057
  noteFallbacks(clack, depSummary);
4741
- await runInstaller(clack, depSummary, input);
5058
+ if (!(await runInstaller(clack, depSummary, input)).installed) {
5059
+ emitInstallNextSteps(clack, depSummary, input.cwd);
5060
+ return false;
5061
+ }
5062
+ return true;
4742
5063
  }
4743
5064
  function performWriteAndCleanup(clack, resolved, prepared) {
4744
5065
  const outcome = writeAndAnnounce(clack, resolved.targetPath, prepared.writePath, prepared.rendered, resolved.format);
4745
5066
  if (prepared.eslintIgnore) safeUnlink(prepared.eslintIgnore.path, clack, ".eslintignore");
4746
- if (outcome.deletedSourceCandidate) safeUnlink(outcome.deletedSourceCandidate, clack, "source config");
5067
+ outcome.deletedSourceCandidates.forEach((path) => {
5068
+ safeUnlink(path, clack, "superseded config");
5069
+ });
5070
+ }
5071
+ function noteLintScriptOutcome$1(clack, result) {
5072
+ if (!clack.note) return;
5073
+ if (result.unresolved) {
5074
+ clack.note(`Left \`${result.unresolved.script}\` untouched — it runs neither eslint nor oxlint:\n\n ${result.unresolved.previous}\n\nAdd \`${OXLINT_LINT_SCRIPT}\` yourself, next to whatever invokes ESLint.`, "package.json script not changed");
5075
+ return;
5076
+ }
5077
+ if (result.alreadyRunsOxlint.length > 0 && result.changes.length === 0) {
5078
+ clack.note(`\`${result.alreadyRunsOxlint.join("`, `")}\` already run oxlint — left alone.`, "package.json scripts unchanged");
5079
+ return;
5080
+ }
5081
+ if (result.skipped.length > 0) clack.note(`${formatSkippedLintScripts(result.skipped)}\n\noxlint was added to one entry point only, so it does not run twice over the same tree.`, "package.json scripts left alone");
5082
+ if (result.changes.length === 0) return;
5083
+ clack.note(`${formatLintScriptChanges(result.changes)}\n\noxlint was prepended, never substituted, so every command that was there still runs.`, "Updated package.json scripts");
4747
5084
  }
4748
5085
  function applyLintScript$1(clack, directory) {
4749
5086
  const manifestPath = (0, node_path.join)(directory, "package.json");
4750
5087
  if (!(0, node_fs.existsSync)(manifestPath)) return;
4751
5088
  const result = setLintScript((0, node_fs.readFileSync)(manifestPath, "utf8"));
4752
- if (!result.changed) return;
4753
- (0, node_fs.writeFileSync)(manifestPath, result.manifest, "utf8");
4754
- if (clack.note) {
4755
- const previous = result.previous ?? "(none)";
4756
- clack.note(`lint: ${previous} -> oxlint --disable-nested-config && eslint .`, "Updated package.json script");
4757
- }
5089
+ if (result.changed) (0, node_fs.writeFileSync)(manifestPath, result.manifest, "utf8");
5090
+ noteLintScriptOutcome$1(clack, result);
4758
5091
  }
4759
- function rewriteDirectivesWithNote(clack, directory) {
4760
- const { rewritten } = rewriteDisableDirectives(directory);
4761
- if (rewritten.length === 0 || !clack.note) return;
4762
- clack.note(`Re-pointed eslint-disable comments onto oxlint-disable in ${rewritten.length} file(s).`, "Disable directives");
5092
+ async function rewriteDirectivesWithNote(clack, directory, autoConfirm) {
5093
+ const candidates = previewDisableDirectives(directory);
5094
+ if (candidates.length === 0) return;
5095
+ const count = candidates.length;
5096
+ if (clack.note) clack.note(`${count} file(s) carry an \`eslint-disable\` naming a rule oxlint now owns. Left as they are, ESLint reports each one as an unused directive.
5097
+
5098
+ This is a separate, source-wide diff — review it on its own commit.`, "Disable directives");
5099
+ if (!autoConfirm) {
5100
+ const proceed = await clack.confirm({
5101
+ message: `Re-point \`eslint-disable\` onto \`oxlint-disable\` in ${count} file(s) now?`,
5102
+ initialValue: true
5103
+ });
5104
+ if (clack.isCancel(proceed) || proceed !== true) {
5105
+ if (clack.note) clack.note("Skipped. Re-run `npx @useinsider/eslint-config migrate` to apply it later.", "Disable directives not rewritten");
5106
+ return;
5107
+ }
5108
+ }
5109
+ const { rewritten } = rewriteDisableDirectives(directory, candidates);
5110
+ if (clack.note) clack.note(`Re-pointed directives in ${rewritten.length} file(s).`, "Disable directives");
4763
5111
  }
4764
5112
  function loadPreviousMigrate(clack, cwd) {
4765
5113
  const restored = loadSession({ cwd });
@@ -4771,7 +5119,7 @@ function emitNothingToMigrate(clack, format) {
4771
5119
  clack.outro(format === "eslintrc" ? "No legacy `.eslintrc` shape found — nothing to migrate." : "No v1 useInsider patterns found — nothing to migrate.");
4772
5120
  }
4773
5121
  function persistMigrateSession(input, prepared, resolved) {
4774
- const overwriteExisting = prepared.writePath !== resolved.targetPath && (0, node_fs.existsSync)(prepared.writePath);
5122
+ const overwriteExisting = supersededFlatConfigs(resolved.targetPath, prepared.writePath).length > 0;
4775
5123
  saveSession({
4776
5124
  command: "migrate",
4777
5125
  migrate: {
@@ -4783,34 +5131,48 @@ function persistMigrateSession(input, prepared, resolved) {
4783
5131
  }
4784
5132
  }, { cwd: input.cwd });
4785
5133
  }
5134
+ async function finishMigrate(clack, depSummary, input) {
5135
+ if (depSummary && !await applyDepChanges(clack, depSummary, input)) {
5136
+ clack.cancel("Config migrated, but the dependencies did not install. Install them and re-run lint.");
5137
+ return 1;
5138
+ }
5139
+ clearSession({ cwd: input.cwd });
5140
+ return 0;
5141
+ }
4786
5142
  async function runMigrate(input, clackOverride) {
4787
5143
  const clack = clackOverride ?? await loadClack();
4788
5144
  const sourceResult = resolveSource(input, clack);
4789
5145
  if (!sourceResult.ok) return sourceResult.exitCode;
4790
5146
  const { resolved } = sourceResult;
4791
5147
  clack.intro(`Migrating ${resolved.targetPath}`);
5148
+ const declinedExitCode = input.fromLint ? 1 : 0;
5149
+ if (input.fromLint && !input.yes && !await confirmGuidedStart(clack, resolved.targetPath)) {
5150
+ clack.outro("Left the config as it is — nothing was written.");
5151
+ return declinedExitCode;
5152
+ }
4792
5153
  const previousMigrate = loadPreviousMigrate(clack, input.cwd);
4793
5154
  const prepared = prepareMigration(resolved, input);
4794
5155
  if (!prepared) {
4795
5156
  emitNothingToMigrate(clack, resolved.format);
4796
- return 0;
5157
+ return declinedExitCode;
4797
5158
  }
4798
5159
  emitSummary(clack, prepared.plan, resolved.format);
5160
+ noteConfigRename(clack, resolved.targetPath, prepared.writePath);
4799
5161
  const depSummary = computeDepSummary(prepared.plan, prepared.writePath, input.overrides);
4800
- if (!await runConfirmations(clack, input, resolved.targetPath, prepared.writePath, previousMigrate)) return 0;
5162
+ if (!await runConfirmations(clack, input, resolved.targetPath, prepared.writePath, previousMigrate)) return declinedExitCode;
4801
5163
  persistMigrateSession(input, prepared, resolved);
4802
5164
  performWriteAndCleanup(clack, resolved, prepared);
4803
5165
  applyLintScript$1(clack, (0, node_path.dirname)(prepared.writePath));
4804
- rewriteDirectivesWithNote(clack, (0, node_path.dirname)(prepared.writePath));
5166
+ await rewriteDirectivesWithNote(clack, (0, node_path.dirname)(prepared.writePath), Boolean(input.yes));
4805
5167
  const consumerDir = (0, node_path.dirname)(prepared.writePath);
5168
+ const consumerLayout = resolveLintLayout(consumerDir);
4806
5169
  await applyTsExtensionsFlag(clack, {
4807
5170
  cwd: consumerDir,
4808
- lintModule: resolveLintLayout(consumerDir).lintModule,
5171
+ lintModule: consumerLayout.lintModule,
5172
+ eslintConfig: consumerLayout.eslintConfig,
4809
5173
  autoConfirm: input.yes
4810
5174
  });
4811
- if (depSummary) await applyDepChanges(clack, depSummary, input);
4812
- clearSession({ cwd: input.cwd });
4813
- return 0;
5175
+ return finishMigrate(clack, depSummary, input);
4814
5176
  }
4815
5177
  //#endregion
4816
5178
  //#region src/cli/index.ts
@@ -4977,9 +5339,19 @@ async function resolveHybridAnswers(clack, input, answers) {
4977
5339
  }
4978
5340
  function applyLintScript(cwd) {
4979
5341
  const manifestPath = (0, node_path.join)(cwd, "package.json");
4980
- if (!(0, node_fs.existsSync)(manifestPath)) return;
5342
+ if (!(0, node_fs.existsSync)(manifestPath)) return null;
4981
5343
  const result = setLintScript((0, node_fs.readFileSync)(manifestPath, "utf8"));
4982
5344
  if (result.changed) (0, node_fs.writeFileSync)(manifestPath, result.manifest, "utf8");
5345
+ return result;
5346
+ }
5347
+ function noteLintScriptOutcome(clack, result) {
5348
+ if (!clack.note || !result) return;
5349
+ if (result.unresolved) {
5350
+ clack.note(`Left \`${result.unresolved.script}\` untouched — it runs neither eslint nor oxlint:\n\n ${result.unresolved.previous}\n\nAdd \`${OXLINT_LINT_SCRIPT}\` yourself, next to whatever invokes ESLint.`, "package.json script not changed");
5351
+ return;
5352
+ }
5353
+ if (result.skipped.length > 0) clack.note(`${formatSkippedLintScripts(result.skipped)}\n\noxlint was added to one entry point only, so it does not run twice over the same tree.`, "package.json scripts left alone");
5354
+ if (result.changes.length > 0) clack.note(formatLintScriptChanges(result.changes), "Updated package.json scripts");
4983
5355
  }
4984
5356
  function writeLintLayout(cwd, lintModuleSource) {
4985
5357
  const layout = resolveLintLayout(cwd);
@@ -4988,21 +5360,26 @@ function writeLintLayout(cwd, lintModuleSource) {
4988
5360
  renderLintLayoutFiles(layout).forEach((file) => {
4989
5361
  (0, node_fs.writeFileSync)((0, node_path.join)(cwd, file.filename), file.source, "utf8");
4990
5362
  });
4991
- applyLintScript(cwd);
4992
- return lintModulePath;
5363
+ layout.replacedEslintConfigs.forEach((filename) => {
5364
+ (0, node_fs.rmSync)((0, node_path.join)(cwd, filename), { force: true });
5365
+ });
5366
+ return {
5367
+ lintModulePath,
5368
+ lintScript: applyLintScript(cwd)
5369
+ };
4993
5370
  }
4994
5371
  async function installAndWrite(clack, input, answers, options) {
4995
5372
  if (options.pruneBeforeInstall) noteOverridePrune(clack, pruneConsumerPackageJson(input.cwd).removed);
4996
5373
  const deps = buildDependencyList(answers);
4997
5374
  if (clack.note) clack.note(deps.map((dep) => ` - ${dep}`).join("\n"), "Installing dev dependencies");
4998
- await installWithRetry({
5375
+ if (!(await installWithRetry({
4999
5376
  deps,
5000
5377
  cwd: input.cwd,
5001
5378
  autoConfirm: Boolean(input.yes),
5002
5379
  clack,
5003
5380
  installer: input.overrides?.installer ?? installDependencies,
5004
5381
  fsOps: input.overrides?.wipeFs
5005
- });
5382
+ })).installed) return null;
5006
5383
  return writeLintLayout(input.cwd, renderLintModuleFile(answers));
5007
5384
  }
5008
5385
  async function runScaffold(input, clackOverride) {
@@ -5024,14 +5401,21 @@ async function runScaffold(input, clackOverride) {
5024
5401
  existingConfigChoice: "override"
5025
5402
  }
5026
5403
  }, { cwd: input.cwd });
5027
- const targetPath = await installAndWrite(clack, input, resolvedAnswers, { pruneBeforeInstall: viaOverride });
5404
+ const written = await installAndWrite(clack, input, resolvedAnswers, { pruneBeforeInstall: viaOverride });
5405
+ if (!written) {
5406
+ clack.cancel("Dependencies were not installed — nothing was written. Fix the install and re-run.");
5407
+ return 1;
5408
+ }
5409
+ noteLintScriptOutcome(clack, written.lintScript);
5410
+ const layout = resolveLintLayout(input.cwd);
5028
5411
  await applyTsExtensionsFlag(clack, {
5029
5412
  cwd: input.cwd,
5030
- lintModule: resolveLintLayout(input.cwd).lintModule,
5413
+ lintModule: layout.lintModule,
5414
+ eslintConfig: layout.eslintConfig,
5031
5415
  autoConfirm: input.yes
5032
5416
  });
5033
5417
  clearSession({ cwd: input.cwd });
5034
- clack.outro(`Wrote ${targetPath} and its sibling linter configs`);
5418
+ clack.outro(`Wrote ${written.lintModulePath} and its sibling linter configs`);
5035
5419
  return 0;
5036
5420
  }
5037
5421
  async function runScaffoldSubcommand(scaffoldInput) {
@@ -5042,7 +5426,8 @@ async function runMigrateSubcommand(input) {
5042
5426
  cwd: input.cwd,
5043
5427
  target: input.target,
5044
5428
  yes: input.yes,
5045
- noInstall: input.noInstall
5429
+ noInstall: input.noInstall,
5430
+ fromLint: input.fromLint
5046
5431
  });
5047
5432
  }
5048
5433
  function writeStderr(line) {