react-doctor 0.0.27 → 0.0.29

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.js CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { execSync, spawn, spawnSync } from "node:child_process";
4
- import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import os, { homedir, tmpdir } from "node:os";
6
6
  import path, { join } from "node:path";
7
7
  import { Command } from "commander";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { performance } from "node:perf_hooks";
10
10
  import pc from "picocolors";
11
+ import basePrompts from "prompts";
11
12
  import { main } from "knip";
12
13
  import { createOptions } from "knip/session";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import ora from "ora";
15
- import basePrompts from "prompts";
16
16
 
17
17
  //#region src/constants.ts
18
18
  const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
@@ -40,6 +40,8 @@ const WARNING_RULE_PENALTY = .75;
40
40
  const ERROR_ESTIMATED_FIX_RATE = .85;
41
41
  const WARNING_ESTIMATED_FIX_RATE = .8;
42
42
  const MAX_KNIP_RETRIES = 5;
43
+ const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
44
+ const OXLINT_RECOMMENDED_NODE_MAJOR = 24;
43
45
  const AMI_WEBSITE_URL = "https://ami.dev";
44
46
  const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;
45
47
  const AMI_RELEASES_URL = "https://github.com/millionco/ami-releases/releases";
@@ -281,6 +283,7 @@ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isD
281
283
  //#region src/utils/find-monorepo-root.ts
282
284
  const isMonorepoRoot = (directory) => {
283
285
  if (fs.existsSync(path.join(directory, "pnpm-workspace.yaml"))) return true;
286
+ if (fs.existsSync(path.join(directory, "nx.json"))) return true;
284
287
  const packageJsonPath = path.join(directory, "package.json");
285
288
  if (!fs.existsSync(packageJsonPath)) return false;
286
289
  const packageJson = readPackageJson(packageJsonPath);
@@ -333,7 +336,9 @@ const FRAMEWORK_PACKAGES = {
333
336
  vite: "vite",
334
337
  "react-scripts": "cra",
335
338
  "@remix-run/react": "remix",
336
- gatsby: "gatsby"
339
+ gatsby: "gatsby",
340
+ expo: "expo",
341
+ "react-native": "react-native"
337
342
  };
338
343
  const FRAMEWORK_DISPLAY_NAMES = {
339
344
  nextjs: "Next.js",
@@ -341,10 +346,34 @@ const FRAMEWORK_DISPLAY_NAMES = {
341
346
  cra: "Create React App",
342
347
  remix: "Remix",
343
348
  gatsby: "Gatsby",
349
+ expo: "Expo",
350
+ "react-native": "React Native",
344
351
  unknown: "React"
345
352
  };
346
353
  const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
347
- const countSourceFiles = (rootDirectory) => {
354
+ const IGNORED_DIRECTORIES = new Set([
355
+ "node_modules",
356
+ "dist",
357
+ "build",
358
+ "coverage"
359
+ ]);
360
+ const countSourceFilesViaFilesystem = (rootDirectory) => {
361
+ let count = 0;
362
+ const stack = [rootDirectory];
363
+ while (stack.length > 0) {
364
+ const currentDirectory = stack.pop();
365
+ const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
366
+ for (const entry of entries) {
367
+ if (entry.isDirectory()) {
368
+ if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
369
+ continue;
370
+ }
371
+ if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
372
+ }
373
+ }
374
+ return count;
375
+ };
376
+ const countSourceFilesViaGit = (rootDirectory) => {
348
377
  const result = spawnSync("git", [
349
378
  "ls-files",
350
379
  "--cached",
@@ -355,9 +384,10 @@ const countSourceFiles = (rootDirectory) => {
355
384
  encoding: "utf-8",
356
385
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
357
386
  });
358
- if (result.error || result.status !== 0) return 0;
387
+ if (result.error || result.status !== 0) return null;
359
388
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
360
389
  };
390
+ const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
361
391
  const collectAllDependencies = (packageJson) => ({
362
392
  ...packageJson.peerDependencies,
363
393
  ...packageJson.dependencies,
@@ -442,14 +472,30 @@ const findReactInWorkspaces = (rootDirectory, packageJson) => {
442
472
  }
443
473
  return result;
444
474
  };
475
+ const REACT_DEPENDENCY_NAMES = new Set([
476
+ "react",
477
+ "react-native",
478
+ "next"
479
+ ]);
445
480
  const hasReactDependency = (packageJson) => {
446
481
  const allDependencies = collectAllDependencies(packageJson);
447
- return Object.keys(allDependencies).some((packageName) => packageName === "next" || packageName.includes("react"));
482
+ return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
448
483
  };
449
484
  const discoverReactSubprojects = (rootDirectory) => {
450
485
  if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
451
- const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
452
486
  const packages = [];
487
+ const rootPackageJsonPath = path.join(rootDirectory, "package.json");
488
+ if (fs.existsSync(rootPackageJsonPath)) {
489
+ const rootPackageJson = readPackageJson(rootPackageJsonPath);
490
+ if (hasReactDependency(rootPackageJson)) {
491
+ const name = rootPackageJson.name ?? path.basename(rootDirectory);
492
+ packages.push({
493
+ name,
494
+ directory: rootDirectory
495
+ });
496
+ }
497
+ }
498
+ const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
453
499
  for (const entry of entries) {
454
500
  if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
455
501
  const subdirectory = path.join(rootDirectory, entry.name);
@@ -621,14 +667,10 @@ const loadConfig = (rootDirectory) => {
621
667
  if (fs.existsSync(configFilePath)) try {
622
668
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
623
669
  const parsed = JSON.parse(fileContent);
624
- if (!isPlainObject(parsed)) {
625
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
626
- return null;
627
- }
628
- return parsed;
670
+ if (isPlainObject(parsed)) return parsed;
671
+ console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
629
672
  } catch (error) {
630
673
  console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
631
- return null;
632
674
  }
633
675
  const packageJsonPath = path.join(rootDirectory, "package.json");
634
676
  if (fs.existsSync(packageJsonPath)) try {
@@ -641,6 +683,167 @@ const loadConfig = (rootDirectory) => {
641
683
  return null;
642
684
  };
643
685
 
686
+ //#endregion
687
+ //#region src/utils/should-auto-select-current-choice.ts
688
+ const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
689
+ if (choiceStates.some((choiceState) => choiceState.selected)) return false;
690
+ const currentChoice = choiceStates[cursor];
691
+ return Boolean(currentChoice) && !currentChoice.disabled;
692
+ };
693
+
694
+ //#endregion
695
+ //#region src/utils/should-select-all-choices.ts
696
+ const shouldSelectAllChoices = (choiceStates) => {
697
+ return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
698
+ };
699
+
700
+ //#endregion
701
+ //#region src/utils/prompts.ts
702
+ const require = createRequire(import.meta.url);
703
+ const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
704
+ const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
705
+ let didPatchMultiselectToggleAll = false;
706
+ let didPatchMultiselectSubmit = false;
707
+ let didPatchSelectBanner = false;
708
+ const selectBannerMap = /* @__PURE__ */ new Map();
709
+ const setSelectBanner = (banner, targetIndex) => {
710
+ selectBannerMap.set(targetIndex, banner);
711
+ };
712
+ const clearSelectBanner = () => {
713
+ selectBannerMap.clear();
714
+ };
715
+ const onCancel = () => {
716
+ logger.break();
717
+ logger.log("Cancelled.");
718
+ logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
719
+ logger.break();
720
+ process.exit(0);
721
+ };
722
+ const patchMultiselectToggleAll = () => {
723
+ if (didPatchMultiselectToggleAll) return;
724
+ didPatchMultiselectToggleAll = true;
725
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
726
+ multiselectPromptConstructor.prototype.toggleAll = function() {
727
+ const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
728
+ if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
729
+ this.bell();
730
+ return;
731
+ }
732
+ const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
733
+ for (const choiceState of this.value) {
734
+ if (choiceState.disabled) continue;
735
+ choiceState.selected = shouldSelectAllEnabledChoices;
736
+ }
737
+ this.render();
738
+ };
739
+ };
740
+ const patchMultiselectSubmit = () => {
741
+ if (didPatchMultiselectSubmit) return;
742
+ didPatchMultiselectSubmit = true;
743
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
744
+ const originalSubmit = multiselectPromptConstructor.prototype.submit;
745
+ multiselectPromptConstructor.prototype.submit = function() {
746
+ if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
747
+ originalSubmit.call(this);
748
+ };
749
+ };
750
+ const patchSelectBanner = () => {
751
+ if (didPatchSelectBanner) return;
752
+ didPatchSelectBanner = true;
753
+ const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
754
+ const promptsClear = require("prompts/lib/util/clear");
755
+ const originalRender = selectConstructor.prototype.render;
756
+ selectConstructor.prototype.render = function() {
757
+ originalRender.call(this);
758
+ const banner = selectBannerMap.get(this.cursor);
759
+ if (!banner || this.closed || this.done) return;
760
+ this.out.write(promptsClear(this.outputText, this.out.columns));
761
+ this.outputText = `${banner}\n\n${this.outputText}`;
762
+ this.out.write(this.outputText);
763
+ };
764
+ };
765
+ const prompts = (questions) => {
766
+ patchMultiselectToggleAll();
767
+ patchMultiselectSubmit();
768
+ patchSelectBanner();
769
+ return basePrompts(questions, { onCancel });
770
+ };
771
+
772
+ //#endregion
773
+ //#region src/utils/resolve-compatible-node.ts
774
+ const parseNodeVersion = (versionString) => {
775
+ const [major = 0, minor = 0, patch = 0] = versionString.replace(/^v/, "").trim().split(".").map(Number);
776
+ return {
777
+ major,
778
+ minor,
779
+ patch
780
+ };
781
+ };
782
+ const isNodeVersionCompatibleWithOxlint = ({ major, minor }) => {
783
+ if (major === 20 && minor >= 19) return true;
784
+ if (major === 22 && minor >= 12) return true;
785
+ if (major > 22) return true;
786
+ return false;
787
+ };
788
+ const isCurrentNodeCompatibleWithOxlint = () => isNodeVersionCompatibleWithOxlint(parseNodeVersion(process.version));
789
+ const getNvmDirectory = () => {
790
+ const envNvmDirectory = process.env.NVM_DIR;
791
+ if (envNvmDirectory && existsSync(envNvmDirectory)) return envNvmDirectory;
792
+ const defaultNvmDirectory = path.join(os.homedir(), ".nvm");
793
+ if (existsSync(defaultNvmDirectory)) return defaultNvmDirectory;
794
+ return null;
795
+ };
796
+ const isNvmInstalled = () => getNvmDirectory() !== null;
797
+ const findCompatibleNvmBinary = () => {
798
+ const nvmDirectory = getNvmDirectory();
799
+ if (!nvmDirectory) return null;
800
+ const versionsDirectory = path.join(nvmDirectory, "versions", "node");
801
+ if (!existsSync(versionsDirectory)) return null;
802
+ const compatibleVersions = readdirSync(versionsDirectory).filter((directoryName) => directoryName.startsWith("v")).map((directoryName) => ({
803
+ directoryName,
804
+ ...parseNodeVersion(directoryName)
805
+ })).filter((version) => isNodeVersionCompatibleWithOxlint(version)).sort((versionA, versionB) => versionB.major - versionA.major || versionB.minor - versionA.minor || versionB.patch - versionA.patch);
806
+ if (compatibleVersions.length === 0) return null;
807
+ const bestVersion = compatibleVersions[0];
808
+ const binaryPath = path.join(versionsDirectory, bestVersion.directoryName, "bin", "node");
809
+ return existsSync(binaryPath) ? binaryPath : null;
810
+ };
811
+ const getNodeVersionFromBinary = (binaryPath) => {
812
+ try {
813
+ return execSync(`"${binaryPath}" --version`, { encoding: "utf-8" }).trim();
814
+ } catch {
815
+ return null;
816
+ }
817
+ };
818
+ const installNodeViaNvm = () => {
819
+ const nvmDirectory = getNvmDirectory();
820
+ if (!nvmDirectory) return false;
821
+ const nvmScript = path.join(nvmDirectory, "nvm.sh");
822
+ if (!existsSync(nvmScript)) return false;
823
+ try {
824
+ execSync(`bash -c ". '${nvmScript}' && nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}"`, { stdio: "inherit" });
825
+ return findCompatibleNvmBinary() !== null;
826
+ } catch {
827
+ return false;
828
+ }
829
+ };
830
+ const resolveNodeForOxlint = () => {
831
+ if (isCurrentNodeCompatibleWithOxlint()) return {
832
+ binaryPath: process.execPath,
833
+ isCurrentNode: true,
834
+ version: process.version
835
+ };
836
+ const nvmBinaryPath = findCompatibleNvmBinary();
837
+ if (!nvmBinaryPath) return null;
838
+ const version = getNodeVersionFromBinary(nvmBinaryPath);
839
+ if (!version) return null;
840
+ return {
841
+ binaryPath: nvmBinaryPath,
842
+ isCurrentNode: false,
843
+ version
844
+ };
845
+ };
846
+
644
847
  //#endregion
645
848
  //#region src/utils/run-knip.ts
646
849
  const KNIP_CATEGORY_MAP = {
@@ -699,11 +902,15 @@ const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
699
902
  const extractFailedPluginName = (error) => {
700
903
  return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
701
904
  };
905
+ const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
906
+ const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
702
907
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
908
+ const tsConfigFile = resolveTsConfigFile(knipCwd);
703
909
  const options = await silenced(() => createOptions({
704
910
  cwd: knipCwd,
705
911
  isShowProgress: false,
706
- ...workspaceName ? { workspace: workspaceName } : {}
912
+ ...workspaceName ? { workspace: workspaceName } : {},
913
+ ...tsConfigFile ? { tsConfigFile } : {}
707
914
  }));
708
915
  const parsedConfig = options.parsedConfig;
709
916
  for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
@@ -775,6 +982,16 @@ const NEXTJS_RULES = {
775
982
  "react-doctor/nextjs-no-head-import": "error",
776
983
  "react-doctor/nextjs-no-side-effect-in-get-handler": "error"
777
984
  };
985
+ const REACT_NATIVE_RULES = {
986
+ "react-doctor/rn-no-raw-text": "error",
987
+ "react-doctor/rn-no-deprecated-modules": "error",
988
+ "react-doctor/rn-no-legacy-expo-packages": "warn",
989
+ "react-doctor/rn-no-dimensions-get": "warn",
990
+ "react-doctor/rn-no-inline-flatlist-renderitem": "warn",
991
+ "react-doctor/rn-no-legacy-shadow-styles": "warn",
992
+ "react-doctor/rn-prefer-reanimated": "warn",
993
+ "react-doctor/rn-no-single-element-style-array": "warn"
994
+ };
778
995
  const REACT_COMPILER_RULES = {
779
996
  "react-hooks-js/set-state-in-render": "error",
780
997
  "react-hooks-js/immutability": "error",
@@ -878,7 +1095,8 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
878
1095
  "react-doctor/server-after-nonblocking": "warn",
879
1096
  "react-doctor/client-passive-event-listeners": "warn",
880
1097
  "react-doctor/async-parallel": "warn",
881
- ...framework === "nextjs" ? NEXTJS_RULES : {}
1098
+ ...framework === "nextjs" ? NEXTJS_RULES : {},
1099
+ ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}
882
1100
  }
883
1101
  });
884
1102
 
@@ -986,7 +1204,15 @@ const RULE_CATEGORY_MAP = {
986
1204
  "react-doctor/server-auth-actions": "Server",
987
1205
  "react-doctor/server-after-nonblocking": "Server",
988
1206
  "react-doctor/client-passive-event-listeners": "Performance",
989
- "react-doctor/async-parallel": "Performance"
1207
+ "react-doctor/async-parallel": "Performance",
1208
+ "react-doctor/rn-no-raw-text": "React Native",
1209
+ "react-doctor/rn-no-deprecated-modules": "React Native",
1210
+ "react-doctor/rn-no-legacy-expo-packages": "React Native",
1211
+ "react-doctor/rn-no-dimensions-get": "React Native",
1212
+ "react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
1213
+ "react-doctor/rn-no-legacy-shadow-styles": "React Native",
1214
+ "react-doctor/rn-prefer-reanimated": "React Native",
1215
+ "react-doctor/rn-no-single-element-style-array": "React Native"
990
1216
  };
991
1217
  const RULE_HELP_MAP = {
992
1218
  "no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`",
@@ -1042,7 +1268,15 @@ const RULE_HELP_MAP = {
1042
1268
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
1043
1269
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
1044
1270
  "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
1045
- "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently"
1271
+ "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1272
+ "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1273
+ "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
1274
+ "rn-no-legacy-expo-packages": "Migrate to the recommended replacement package — legacy Expo packages are no longer maintained",
1275
+ "rn-no-dimensions-get": "Use `const { width, height } = useWindowDimensions()` — it updates reactively on rotation and resize",
1276
+ "rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
1277
+ "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1278
+ "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1279
+ "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation"
1046
1280
  };
1047
1281
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
1048
1282
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
@@ -1102,8 +1336,8 @@ const batchIncludePaths = (baseArgs, includePaths) => {
1102
1336
  if (currentBatch.length > 0) batches.push(currentBatch);
1103
1337
  return batches;
1104
1338
  };
1105
- const spawnOxlint = (args, rootDirectory) => new Promise((resolve, reject) => {
1106
- const child = spawn(process.execPath, args, { cwd: rootDirectory });
1339
+ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
1340
+ const child = spawn(nodeBinaryPath, args, { cwd: rootDirectory });
1107
1341
  const stdoutBuffers = [];
1108
1342
  const stderrBuffers = [];
1109
1343
  child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
@@ -1146,7 +1380,7 @@ const parseOxlintOutput = (stdout) => {
1146
1380
  };
1147
1381
  });
1148
1382
  };
1149
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths) => {
1383
+ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath) => {
1150
1384
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1151
1385
  const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
1152
1386
  const config = createOxlintConfig({
@@ -1168,7 +1402,7 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1168
1402
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
1169
1403
  const allDiagnostics = [];
1170
1404
  for (const batch of fileBatches) {
1171
- const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory);
1405
+ const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
1172
1406
  allDiagnostics.push(...parseOxlintOutput(stdout));
1173
1407
  }
1174
1408
  return allDiagnostics;
@@ -1393,6 +1627,44 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
1393
1627
  logger.break();
1394
1628
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
1395
1629
  };
1630
+ const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
1631
+ if (!isLintEnabled) return null;
1632
+ const nodeResolution = resolveNodeForOxlint();
1633
+ if (nodeResolution) {
1634
+ if (!nodeResolution.isCurrentNode && !isScoreOnly) {
1635
+ logger.warn(`Node ${process.version} is unsupported by oxlint. Using Node ${nodeResolution.version} from nvm.`);
1636
+ logger.break();
1637
+ }
1638
+ return nodeResolution.binaryPath;
1639
+ }
1640
+ if (isScoreOnly) return null;
1641
+ logger.warn(`Node ${process.version} is not compatible with oxlint (requires ${OXLINT_NODE_REQUIREMENT}). Lint checks will be skipped.`);
1642
+ if (isNvmInstalled() && process.stdin.isTTY) {
1643
+ const { shouldInstallNode } = await prompts({
1644
+ type: "confirm",
1645
+ name: "shouldInstallNode",
1646
+ message: `Install Node ${OXLINT_RECOMMENDED_NODE_MAJOR} via nvm to enable lint checks?`,
1647
+ initial: true
1648
+ });
1649
+ if (shouldInstallNode) {
1650
+ logger.break();
1651
+ const freshResolution = installNodeViaNvm() ? resolveNodeForOxlint() : null;
1652
+ if (freshResolution) {
1653
+ logger.break();
1654
+ logger.success(`Node ${freshResolution.version} installed. Using it for lint checks.`);
1655
+ logger.break();
1656
+ return freshResolution.binaryPath;
1657
+ }
1658
+ logger.break();
1659
+ logger.warn("Failed to install Node via nvm. Skipping lint checks.");
1660
+ logger.break();
1661
+ return null;
1662
+ }
1663
+ } else if (isNvmInstalled()) logger.dim(` Run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
1664
+ else logger.dim(` Install nvm (https://github.com/nvm-sh/nvm) and run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
1665
+ logger.break();
1666
+ return null;
1667
+ };
1396
1668
  const mergeScanOptions = (inputOptions, userConfig) => ({
1397
1669
  lint: inputOptions.lint ?? userConfig?.lint ?? true,
1398
1670
  deadCode: inputOptions.deadCode ?? userConfig?.deadCode ?? true,
@@ -1428,19 +1700,26 @@ const scan = async (directory, inputOptions = {}) => {
1428
1700
  const jsxIncludePaths = computeJsxIncludePaths(includePaths);
1429
1701
  let didLintFail = false;
1430
1702
  let didDeadCodeFail = false;
1431
- const lintPromise = options.lint ? (async () => {
1703
+ const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly);
1704
+ if (options.lint && !resolvedNodeBinaryPath) didLintFail = true;
1705
+ const lintPromise = resolvedNodeBinaryPath ? (async () => {
1432
1706
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
1433
1707
  try {
1434
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths);
1708
+ const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, jsxIncludePaths, resolvedNodeBinaryPath);
1435
1709
  lintSpinner?.succeed("Running lint checks.");
1436
1710
  return lintDiagnostics;
1437
1711
  } catch (error) {
1438
1712
  didLintFail = true;
1439
- lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1440
- if (error instanceof Error) {
1441
- logger.error(error.message);
1442
- if (error.stack) logger.dim(error.stack);
1443
- } else logger.error(String(error));
1713
+ if (!options.scoreOnly) {
1714
+ const errorMessage = error instanceof Error ? error.message : String(error);
1715
+ if (errorMessage.includes("native binding")) {
1716
+ lintSpinner?.fail(`Lint checks failed — oxlint native binding not found (Node ${process.version}).`);
1717
+ logger.dim(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`);
1718
+ } else {
1719
+ lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
1720
+ logger.error(errorMessage);
1721
+ }
1722
+ }
1444
1723
  return [];
1445
1724
  }
1446
1725
  })() : Promise.resolve([]);
@@ -1452,8 +1731,10 @@ const scan = async (directory, inputOptions = {}) => {
1452
1731
  return knipDiagnostics;
1453
1732
  } catch (error) {
1454
1733
  didDeadCodeFail = true;
1455
- deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1456
- logger.error(String(error));
1734
+ if (!options.scoreOnly) {
1735
+ deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
1736
+ logger.error(String(error));
1737
+ }
1457
1738
  return [];
1458
1739
  }
1459
1740
  })() : Promise.resolve([]);
@@ -1603,92 +1884,6 @@ const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
1603
1884
  process.exitCode = 1;
1604
1885
  };
1605
1886
 
1606
- //#endregion
1607
- //#region src/utils/should-auto-select-current-choice.ts
1608
- const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
1609
- if (choiceStates.some((choiceState) => choiceState.selected)) return false;
1610
- const currentChoice = choiceStates[cursor];
1611
- return Boolean(currentChoice) && !currentChoice.disabled;
1612
- };
1613
-
1614
- //#endregion
1615
- //#region src/utils/should-select-all-choices.ts
1616
- const shouldSelectAllChoices = (choiceStates) => {
1617
- return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
1618
- };
1619
-
1620
- //#endregion
1621
- //#region src/utils/prompts.ts
1622
- const require = createRequire(import.meta.url);
1623
- const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
1624
- const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
1625
- let didPatchMultiselectToggleAll = false;
1626
- let didPatchMultiselectSubmit = false;
1627
- let didPatchSelectBanner = false;
1628
- const selectBannerMap = /* @__PURE__ */ new Map();
1629
- const setSelectBanner = (banner, targetIndex) => {
1630
- selectBannerMap.set(targetIndex, banner);
1631
- };
1632
- const clearSelectBanner = () => {
1633
- selectBannerMap.clear();
1634
- };
1635
- const onCancel = () => {
1636
- logger.break();
1637
- logger.log("Cancelled.");
1638
- logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
1639
- logger.break();
1640
- process.exit(0);
1641
- };
1642
- const patchMultiselectToggleAll = () => {
1643
- if (didPatchMultiselectToggleAll) return;
1644
- didPatchMultiselectToggleAll = true;
1645
- const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
1646
- multiselectPromptConstructor.prototype.toggleAll = function() {
1647
- const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
1648
- if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
1649
- this.bell();
1650
- return;
1651
- }
1652
- const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
1653
- for (const choiceState of this.value) {
1654
- if (choiceState.disabled) continue;
1655
- choiceState.selected = shouldSelectAllEnabledChoices;
1656
- }
1657
- this.render();
1658
- };
1659
- };
1660
- const patchMultiselectSubmit = () => {
1661
- if (didPatchMultiselectSubmit) return;
1662
- didPatchMultiselectSubmit = true;
1663
- const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
1664
- const originalSubmit = multiselectPromptConstructor.prototype.submit;
1665
- multiselectPromptConstructor.prototype.submit = function() {
1666
- if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
1667
- originalSubmit.call(this);
1668
- };
1669
- };
1670
- const patchSelectBanner = () => {
1671
- if (didPatchSelectBanner) return;
1672
- didPatchSelectBanner = true;
1673
- const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
1674
- const promptsClear = require("prompts/lib/util/clear");
1675
- const originalRender = selectConstructor.prototype.render;
1676
- selectConstructor.prototype.render = function() {
1677
- originalRender.call(this);
1678
- const banner = selectBannerMap.get(this.cursor);
1679
- if (!banner || this.closed || this.done) return;
1680
- this.out.write(promptsClear(this.outputText, this.out.columns));
1681
- this.outputText = `${banner}\n\n${this.outputText}`;
1682
- this.out.write(this.outputText);
1683
- };
1684
- };
1685
- const prompts = (questions) => {
1686
- patchMultiselectToggleAll();
1687
- patchMultiselectSubmit();
1688
- patchSelectBanner();
1689
- return basePrompts(questions, { onCancel });
1690
- };
1691
-
1692
1887
  //#endregion
1693
1888
  //#region src/utils/select-projects.ts
1694
1889
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
@@ -1909,7 +2104,18 @@ const maybePromptSkillInstall = async (shouldSkipPrompts) => {
1909
2104
 
1910
2105
  //#endregion
1911
2106
  //#region src/cli.ts
1912
- const VERSION = "0.0.27";
2107
+ const VERSION = "0.0.29";
2108
+ const VALID_FAIL_ON_LEVELS = new Set([
2109
+ "error",
2110
+ "warning",
2111
+ "none"
2112
+ ]);
2113
+ const isValidFailOnLevel = (level) => VALID_FAIL_ON_LEVELS.has(level);
2114
+ const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
2115
+ if (failOnLevel === "none") return false;
2116
+ if (failOnLevel === "warning") return diagnostics.length > 0;
2117
+ return diagnostics.some((diagnostic) => diagnostic.severity === "error");
2118
+ };
1913
2119
  const exitWithFixHint = () => {
1914
2120
  logger.break();
1915
2121
  logger.log("Cancelled.");
@@ -1932,8 +2138,8 @@ const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVa
1932
2138
  const resolveCliScanOptions = (flags, userConfig, programInstance) => {
1933
2139
  const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
1934
2140
  return {
1935
- lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? flags.lint,
1936
- deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? flags.deadCode,
2141
+ lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? true,
2142
+ deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? true,
1937
2143
  verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
1938
2144
  scoreOnly: flags.score,
1939
2145
  offline: flags.offline
@@ -1961,7 +2167,7 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
1961
2167
  });
1962
2168
  return Boolean(shouldScanChangedOnly);
1963
2169
  };
1964
- const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--no-ami", "skip Ami-related prompts").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
2170
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--lint", "enable linting").option("--no-lint", "skip linting").option("--dead-code", "enable dead code detection").option("--no-dead-code", "skip dead code detection").option("--verbose", "show file details per rule").option("--score", "output only the score").option("-y, --yes", "skip prompts, scan all workspace projects").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--no-ami", "skip Ami-related prompts").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--fix", "open Ami to auto-fix all issues").action(async (directory, flags) => {
1965
2171
  const isScoreOnly = flags.score;
1966
2172
  try {
1967
2173
  const resolvedDirectory = path.resolve(directory);
@@ -2011,6 +2217,8 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2011
2217
  allDiagnostics.push(...scanResult.diagnostics);
2012
2218
  if (!isScoreOnly) logger.break();
2013
2219
  }
2220
+ const resolvedFailOn = program.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
2221
+ if (shouldFailForDiagnostics(allDiagnostics, isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none")) process.exitCode = 1;
2014
2222
  if (flags.fix) openAmiToFix(resolvedDirectory);
2015
2223
  if (!isScoreOnly && !shouldSkipAmiPrompts && !flags.fix) {
2016
2224
  await maybePromptSkillInstall(shouldSkipAmiPrompts);
@@ -2073,7 +2281,11 @@ const openAmiToFix = (directory) => {
2073
2281
  if (!isInstalled) {
2074
2282
  if (process.platform === "darwin") {
2075
2283
  installAmi();
2076
- logger.success("Ami installed successfully.");
2284
+ if (isAmiInstalled()) logger.success("Ami installed successfully.");
2285
+ else {
2286
+ logger.error("Installation could not be verified.");
2287
+ logger.dim(`Install manually at ${highlighter.info(AMI_WEBSITE_URL)}`);
2288
+ }
2077
2289
  } else {
2078
2290
  logger.error("Ami is not installed.");
2079
2291
  logger.dim(`Download at ${highlighter.info(AMI_RELEASES_URL)}`);