Skip to content

Commit bd949cc

Browse files
committed
refactor: update node version requirements and enhance linting process with improved error handling
1 parent a39c6f2 commit bd949cc

4 files changed

Lines changed: 208 additions & 11 deletions

File tree

packages/react-doctor/src/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export const WARNING_ESTIMATED_FIX_RATE = 0.8;
5151

5252
export const MAX_KNIP_RETRIES = 5;
5353

54+
export const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
55+
56+
export const OXLINT_RECOMMENDED_NODE_MAJOR = 24;
57+
5458
export const AMI_WEBSITE_URL = "https://ami.dev";
5559

5660
export const AMI_INSTALL_URL = `${AMI_WEBSITE_URL}/install.sh`;

packages/react-doctor/src/scan.ts

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
MILLISECONDS_PER_SECOND,
88
OFFLINE_FLAG_MESSAGE,
99
OFFLINE_MESSAGE,
10+
OXLINT_NODE_REQUIREMENT,
11+
OXLINT_RECOMMENDED_NODE_MAJOR,
1012
PERFECT_SCORE,
1113
SCORE_BAR_WIDTH_CHARS,
1214
SCORE_GOOD_THRESHOLD,
@@ -31,6 +33,12 @@ import { highlighter } from "./utils/highlighter.js";
3133
import { indentMultilineText } from "./utils/indent-multiline-text.js";
3234
import { loadConfig } from "./utils/load-config.js";
3335
import { logger } from "./utils/logger.js";
36+
import { prompts } from "./utils/prompts.js";
37+
import {
38+
installNodeViaNvm,
39+
isNvmInstalled,
40+
resolveNodeForOxlint,
41+
} from "./utils/resolve-compatible-node.js";
3442
import { runKnip } from "./utils/run-knip.js";
3543
import { runOxlint } from "./utils/run-oxlint.js";
3644
import { spinner } from "./utils/spinner.js";
@@ -331,6 +339,64 @@ const printSummary = (
331339
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
332340
};
333341

342+
const resolveOxlintNode = async (
343+
isLintEnabled: boolean,
344+
isScoreOnly: boolean,
345+
): Promise<string | null> => {
346+
if (!isLintEnabled) return null;
347+
348+
const nodeResolution = resolveNodeForOxlint();
349+
350+
if (nodeResolution) {
351+
if (!nodeResolution.isCurrentNode && !isScoreOnly) {
352+
logger.warn(
353+
`Node ${process.version} is unsupported by oxlint. Using Node ${nodeResolution.version} from nvm.`,
354+
);
355+
logger.break();
356+
}
357+
return nodeResolution.binaryPath;
358+
}
359+
360+
if (isScoreOnly) return null;
361+
362+
logger.warn(
363+
`Node ${process.version} is not compatible with oxlint (requires ${OXLINT_NODE_REQUIREMENT}). Lint checks will be skipped.`,
364+
);
365+
366+
if (isNvmInstalled() && process.stdin.isTTY) {
367+
const { shouldInstallNode } = await prompts({
368+
type: "confirm",
369+
name: "shouldInstallNode",
370+
message: `Install Node ${OXLINT_RECOMMENDED_NODE_MAJOR} via nvm to enable lint checks?`,
371+
initial: true,
372+
});
373+
374+
if (shouldInstallNode) {
375+
logger.break();
376+
const freshResolution = installNodeViaNvm() ? resolveNodeForOxlint() : null;
377+
if (freshResolution) {
378+
logger.break();
379+
logger.success(`Node ${freshResolution.version} installed. Using it for lint checks.`);
380+
logger.break();
381+
return freshResolution.binaryPath;
382+
}
383+
logger.break();
384+
logger.warn("Failed to install Node via nvm. Skipping lint checks.");
385+
logger.break();
386+
return null;
387+
}
388+
} else if (isNvmInstalled()) {
389+
logger.dim(` Run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
390+
} else {
391+
logger.dim(
392+
` Install nvm (https://github.com/nvm-sh/nvm) and run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`,
393+
);
394+
}
395+
396+
logger.break();
397+
return null;
398+
};
399+
334400
interface ResolvedScanOptions {
335401
lint: boolean;
336402
deadCode: boolean;
@@ -411,7 +477,10 @@ export const scan = async (
411477
let didLintFail = false;
412478
let didDeadCodeFail = false;
413479

414-
const lintPromise = options.lint
480+
const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly);
481+
if (options.lint && !resolvedNodeBinaryPath) didLintFail = true;
482+
483+
const lintPromise = resolvedNodeBinaryPath
415484
? (async () => {
416485
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
417486
try {
@@ -421,19 +490,25 @@ export const scan = async (
421490
projectInfo.framework,
422491
projectInfo.hasReactCompiler,
423492
jsxIncludePaths,
493+
resolvedNodeBinaryPath,
424494
);
425495
lintSpinner?.succeed("Running lint checks.");
426496
return lintDiagnostics;
427497
} catch (error) {
428498
didLintFail = true;
429-
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
430-
if (error instanceof Error) {
431-
logger.error(error.message);
432-
if (error.stack) {
433-
logger.dim(error.stack);
434-
}
499+
const errorMessage = error instanceof Error ? error.message : String(error);
500+
const isNativeBindingError = errorMessage.includes("native binding");
501+
502+
if (isNativeBindingError) {
503+
lintSpinner?.fail(
504+
`Lint checks failed — oxlint native binding not found (Node ${process.version}).`,
505+
);
506+
logger.dim(
507+
` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`,
508+
);
435509
} else {
436-
logger.error(String(error));
510+
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
511+
logger.error(errorMessage);
437512
}
438513
return [];
439514
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { execSync } from "node:child_process";
2+
import { existsSync, readdirSync } from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { OXLINT_RECOMMENDED_NODE_MAJOR } from "../constants.js";
6+
7+
interface NodeVersion {
8+
major: number;
9+
minor: number;
10+
patch: number;
11+
}
12+
13+
interface NodeResolution {
14+
binaryPath: string;
15+
isCurrentNode: boolean;
16+
version: string;
17+
}
18+
19+
const parseNodeVersion = (versionString: string): NodeVersion => {
20+
const cleaned = versionString.replace(/^v/, "").trim();
21+
const [major = 0, minor = 0, patch = 0] = cleaned.split(".").map(Number);
22+
return { major, minor, patch };
23+
};
24+
25+
const isNodeVersionCompatibleWithOxlint = ({ major, minor }: NodeVersion): boolean => {
26+
if (major === 20 && minor >= 19) return true;
27+
if (major === 22 && minor >= 12) return true;
28+
if (major > 22) return true;
29+
return false;
30+
};
31+
32+
const isCurrentNodeCompatibleWithOxlint = (): boolean =>
33+
isNodeVersionCompatibleWithOxlint(parseNodeVersion(process.version));
34+
35+
const getNvmDirectory = (): string | null => {
36+
const envNvmDirectory = process.env.NVM_DIR;
37+
if (envNvmDirectory && existsSync(envNvmDirectory)) return envNvmDirectory;
38+
39+
const defaultNvmDirectory = path.join(os.homedir(), ".nvm");
40+
if (existsSync(defaultNvmDirectory)) return defaultNvmDirectory;
41+
42+
return null;
43+
};
44+
45+
export const isNvmInstalled = (): boolean => getNvmDirectory() !== null;
46+
47+
const findCompatibleNvmBinary = (): string | null => {
48+
const nvmDirectory = getNvmDirectory();
49+
if (!nvmDirectory) return null;
50+
51+
const versionsDirectory = path.join(nvmDirectory, "versions", "node");
52+
if (!existsSync(versionsDirectory)) return null;
53+
54+
const compatibleVersions = readdirSync(versionsDirectory)
55+
.filter((directoryName) => directoryName.startsWith("v"))
56+
.map((directoryName) => ({ directoryName, ...parseNodeVersion(directoryName) }))
57+
.filter((version) => isNodeVersionCompatibleWithOxlint(version))
58+
.sort(
59+
(versionA, versionB) =>
60+
versionB.major - versionA.major ||
61+
versionB.minor - versionA.minor ||
62+
versionB.patch - versionA.patch,
63+
);
64+
65+
if (compatibleVersions.length === 0) return null;
66+
67+
const bestVersion = compatibleVersions[0];
68+
const binaryPath = path.join(versionsDirectory, bestVersion.directoryName, "bin", "node");
69+
return existsSync(binaryPath) ? binaryPath : null;
70+
};
71+
72+
const getNodeVersionFromBinary = (binaryPath: string): string | null => {
73+
try {
74+
return execSync(`"${binaryPath}" --version`, { encoding: "utf-8" }).trim();
75+
} catch {
76+
return null;
77+
}
78+
};
79+
80+
export const installNodeViaNvm = (): boolean => {
81+
const nvmDirectory = getNvmDirectory();
82+
if (!nvmDirectory) return false;
83+
84+
const nvmScript = path.join(nvmDirectory, "nvm.sh");
85+
if (!existsSync(nvmScript)) return false;
86+
87+
try {
88+
execSync(`bash -c ". '${nvmScript}' && nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}"`, {
89+
stdio: "inherit",
90+
});
91+
return findCompatibleNvmBinary() !== null;
92+
} catch {
93+
return false;
94+
}
95+
};
96+
97+
export const resolveNodeForOxlint = (): NodeResolution | null => {
98+
if (isCurrentNodeCompatibleWithOxlint()) {
99+
return {
100+
binaryPath: process.execPath,
101+
isCurrentNode: true,
102+
version: process.version,
103+
};
104+
}
105+
106+
const nvmBinaryPath = findCompatibleNvmBinary();
107+
if (!nvmBinaryPath) return null;
108+
109+
const version = getNodeVersionFromBinary(nvmBinaryPath);
110+
if (!version) return null;
111+
112+
return { binaryPath: nvmBinaryPath, isCurrentNode: false, version };
113+
};

packages/react-doctor/src/utils/run-oxlint.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,13 @@ const batchIncludePaths = (baseArgs: string[], includePaths: string[]): string[]
283283
return batches;
284284
};
285285

286-
const spawnOxlint = (args: string[], rootDirectory: string): Promise<string> =>
286+
const spawnOxlint = (
287+
args: string[],
288+
rootDirectory: string,
289+
nodeBinaryPath: string,
290+
): Promise<string> =>
287291
new Promise<string>((resolve, reject) => {
288-
const child = spawn(process.execPath, args, {
292+
const child = spawn(nodeBinaryPath, args, {
289293
cwd: rootDirectory,
290294
});
291295

@@ -349,6 +353,7 @@ export const runOxlint = async (
349353
framework: Framework,
350354
hasReactCompiler: boolean,
351355
includePaths?: string[],
356+
nodeBinaryPath: string = process.execPath,
352357
): Promise<Diagnostic[]> => {
353358
if (includePaths !== undefined && includePaths.length === 0) {
354359
return [];
@@ -375,7 +380,7 @@ export const runOxlint = async (
375380
const allDiagnostics: Diagnostic[] = [];
376381
for (const batch of fileBatches) {
377382
const batchArgs = [...baseArgs, ...batch];
378-
const stdout = await spawnOxlint(batchArgs, rootDirectory);
383+
const stdout = await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath);
379384
allDiagnostics.push(...parseOxlintOutput(stdout));
380385
}
381386

0 commit comments

Comments
 (0)