Skip to content

Commit e90b794

Browse files
committed
fix
1 parent f9d911c commit e90b794

File tree

4 files changed

+138
-69
lines changed

4 files changed

+138
-69
lines changed

packages/react-doctor/src/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export const FETCH_TIMEOUT_MS = 10_000;
3030

3131
export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
3232

33+
// HACK: Windows CreateProcessW limits total command-line length to 32,767 chars.
34+
// Use a conservative threshold to leave room for the executable path and quoting overhead.
35+
export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000;
36+
3337
export const OFFLINE_MESSAGE =
3438
"You are offline, could not calculate score. Reconnect to calculate.";
3539

packages/react-doctor/src/scan.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,9 @@ export const scan = async (
372372
? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath))
373373
: undefined;
374374

375+
let didLintFail = false;
376+
let didDeadCodeFail = false;
377+
375378
const lintPromise = options.lint
376379
? (async () => {
377380
const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
@@ -386,6 +389,7 @@ export const scan = async (
386389
lintSpinner?.succeed("Running lint checks.");
387390
return lintDiagnostics;
388391
} catch (error) {
392+
didLintFail = true;
389393
lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
390394
if (error instanceof Error) {
391395
logger.error(error.message);
@@ -411,6 +415,7 @@ export const scan = async (
411415
deadCodeSpinner?.succeed("Detecting dead code.");
412416
return knipDiagnostics;
413417
} catch (error) {
418+
didDeadCodeFail = true;
414419
deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
415420
logger.error(String(error));
416421
return [];
@@ -430,6 +435,11 @@ export const scan = async (
430435

431436
const elapsedMilliseconds = performance.now() - startTime;
432437

438+
const skippedChecks: string[] = [];
439+
if (didLintFail) skippedChecks.push("lint");
440+
if (didDeadCodeFail) skippedChecks.push("dead code");
441+
const hasSkippedChecks = skippedChecks.length > 0;
442+
433443
const scoreResult = options.offline ? null : await calculateScore(diagnostics);
434444
const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
435445

@@ -439,19 +449,29 @@ export const scan = async (
439449
} else {
440450
logger.dim(noScoreMessage);
441451
}
442-
return { diagnostics, scoreResult };
452+
return { diagnostics, scoreResult, skippedChecks };
443453
}
444454

445455
if (diagnostics.length === 0) {
446-
logger.success("No issues found!");
456+
if (hasSkippedChecks) {
457+
const skippedLabel = skippedChecks.join(" and ");
458+
logger.warn(
459+
`No issues detected, but ${skippedLabel} checks failed — results are incomplete.`,
460+
);
461+
} else {
462+
logger.success("No issues found!");
463+
}
447464
logger.break();
448-
if (scoreResult) {
465+
if (hasSkippedChecks) {
466+
printBranding();
467+
logger.dim(" Score not shown — some checks could not complete.");
468+
} else if (scoreResult) {
449469
printBranding(scoreResult.score);
450470
printScoreGauge(scoreResult.score, scoreResult.label);
451471
} else {
452472
logger.dim(` ${noScoreMessage}`);
453473
}
454-
return { diagnostics, scoreResult };
474+
return { diagnostics, scoreResult, skippedChecks };
455475
}
456476

457477
printDiagnostics(diagnostics, options.verbose);
@@ -467,5 +487,11 @@ export const scan = async (
467487
noScoreMessage,
468488
);
469489

470-
return { diagnostics, scoreResult };
490+
if (hasSkippedChecks) {
491+
const skippedLabel = skippedChecks.join(" and ");
492+
logger.break();
493+
logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`);
494+
}
495+
496+
return { diagnostics, scoreResult, skippedChecks };
471497
};

packages/react-doctor/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export interface ScoreResult {
8686
export interface ScanResult {
8787
diagnostics: Diagnostic[];
8888
scoreResult: ScoreResult | null;
89+
skippedChecks: string[];
8990
}
9091

9192
export interface EstimatedScoreResult {

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

Lines changed: 102 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { createRequire } from "node:module";
44
import os from "node:os";
55
import path from "node:path";
66
import { fileURLToPath } from "node:url";
7-
import { ERROR_PREVIEW_LENGTH_CHARS, JSX_FILE_PATTERN } from "../constants.js";
7+
import {
8+
ERROR_PREVIEW_LENGTH_CHARS,
9+
JSX_FILE_PATTERN,
10+
SPAWN_ARGS_MAX_LENGTH_CHARS,
11+
} from "../constants.js";
812
import { createOxlintConfig } from "../oxlint-config.js";
913
import type { CleanedDiagnostic, Diagnostic, Framework, OxlintOutput } from "../types.js";
1014
import { neutralizeDisableDirectives } from "./neutralize-disable-directives.js";
@@ -252,6 +256,93 @@ const resolveDiagnosticCategory = (plugin: string, rule: string): string => {
252256
return RULE_CATEGORY_MAP[ruleKey] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
253257
};
254258

259+
const estimateArgsLength = (args: string[]): number =>
260+
args.reduce((total, argument) => total + argument.length + 1, 0);
261+
262+
const batchIncludePaths = (baseArgs: string[], includePaths: string[]): string[][] => {
263+
const baseArgsLength = estimateArgsLength(baseArgs);
264+
const batches: string[][] = [];
265+
let currentBatch: string[] = [];
266+
let currentBatchLength = baseArgsLength;
267+
268+
for (const filePath of includePaths) {
269+
const entryLength = filePath.length + 1;
270+
if (currentBatch.length > 0 && currentBatchLength + entryLength > SPAWN_ARGS_MAX_LENGTH_CHARS) {
271+
batches.push(currentBatch);
272+
currentBatch = [];
273+
currentBatchLength = baseArgsLength;
274+
}
275+
currentBatch.push(filePath);
276+
currentBatchLength += entryLength;
277+
}
278+
279+
if (currentBatch.length > 0) {
280+
batches.push(currentBatch);
281+
}
282+
283+
return batches;
284+
};
285+
286+
const spawnOxlint = (args: string[], rootDirectory: string): Promise<string> =>
287+
new Promise<string>((resolve, reject) => {
288+
const child = spawn(process.execPath, args, {
289+
cwd: rootDirectory,
290+
});
291+
292+
const stdoutBuffers: Buffer[] = [];
293+
const stderrBuffers: Buffer[] = [];
294+
295+
child.stdout.on("data", (buffer: Buffer) => stdoutBuffers.push(buffer));
296+
child.stderr.on("data", (buffer: Buffer) => stderrBuffers.push(buffer));
297+
298+
child.on("error", (error) => reject(new Error(`Failed to run oxlint: ${error.message}`)));
299+
child.on("close", () => {
300+
const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
301+
if (!output) {
302+
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
303+
if (stderrOutput) {
304+
reject(new Error(`Failed to run oxlint: ${stderrOutput}`));
305+
return;
306+
}
307+
}
308+
resolve(output);
309+
});
310+
});
311+
312+
const parseOxlintOutput = (stdout: string): Diagnostic[] => {
313+
if (!stdout) return [];
314+
315+
let output: OxlintOutput;
316+
try {
317+
output = JSON.parse(stdout) as OxlintOutput;
318+
} catch {
319+
throw new Error(
320+
`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`,
321+
);
322+
}
323+
324+
return output.diagnostics
325+
.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename))
326+
.map((diagnostic) => {
327+
const { plugin, rule } = parseRuleCode(diagnostic.code);
328+
const primaryLabel = diagnostic.labels[0];
329+
330+
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
331+
332+
return {
333+
filePath: diagnostic.filename,
334+
plugin,
335+
rule,
336+
severity: diagnostic.severity,
337+
message: cleaned.message,
338+
help: cleaned.help,
339+
line: primaryLabel?.span.line ?? 0,
340+
column: primaryLabel?.span.column ?? 0,
341+
category: resolveDiagnosticCategory(plugin, rule),
342+
};
343+
});
344+
};
345+
255346
export const runOxlint = async (
256347
rootDirectory: string,
257348
hasTypeScript: boolean,
@@ -272,76 +363,23 @@ export const runOxlint = async (
272363
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
273364

274365
const oxlintBinary = resolveOxlintBinary();
275-
const args = [oxlintBinary, "-c", configPath, "--format", "json"];
366+
const baseArgs = [oxlintBinary, "-c", configPath, "--format", "json"];
276367

277368
if (hasTypeScript) {
278-
args.push("--tsconfig", "./tsconfig.json");
369+
baseArgs.push("--tsconfig", "./tsconfig.json");
279370
}
280371

281-
if (includePaths !== undefined) {
282-
args.push(...includePaths);
283-
} else {
284-
args.push(".");
285-
}
286-
287-
const stdout = await new Promise<string>((resolve, reject) => {
288-
const child = spawn(process.execPath, args, {
289-
cwd: rootDirectory,
290-
});
291-
292-
const stdoutBuffers: Buffer[] = [];
293-
const stderrBuffers: Buffer[] = [];
294-
295-
child.stdout.on("data", (buffer: Buffer) => stdoutBuffers.push(buffer));
296-
child.stderr.on("data", (buffer: Buffer) => stderrBuffers.push(buffer));
297-
298-
child.on("error", (error) => reject(new Error(`Failed to run oxlint: ${error.message}`)));
299-
child.on("close", () => {
300-
const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
301-
if (!output) {
302-
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
303-
if (stderrOutput) {
304-
reject(new Error(`Failed to run oxlint: ${stderrOutput}`));
305-
return;
306-
}
307-
}
308-
resolve(output);
309-
});
310-
});
311-
312-
if (!stdout) {
313-
return [];
314-
}
372+
const fileBatches =
373+
includePaths !== undefined ? batchIncludePaths(baseArgs, includePaths) : [["."]];
315374

316-
let output: OxlintOutput;
317-
try {
318-
output = JSON.parse(stdout) as OxlintOutput;
319-
} catch {
320-
throw new Error(
321-
`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`,
322-
);
375+
const allDiagnostics: Diagnostic[] = [];
376+
for (const batch of fileBatches) {
377+
const batchArgs = [...baseArgs, ...batch];
378+
const stdout = await spawnOxlint(batchArgs, rootDirectory);
379+
allDiagnostics.push(...parseOxlintOutput(stdout));
323380
}
324381

325-
return output.diagnostics
326-
.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename))
327-
.map((diagnostic) => {
328-
const { plugin, rule } = parseRuleCode(diagnostic.code);
329-
const primaryLabel = diagnostic.labels[0];
330-
331-
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
332-
333-
return {
334-
filePath: diagnostic.filename,
335-
plugin,
336-
rule,
337-
severity: diagnostic.severity,
338-
message: cleaned.message,
339-
help: cleaned.help,
340-
line: primaryLabel?.span.line ?? 0,
341-
column: primaryLabel?.span.column ?? 0,
342-
category: resolveDiagnosticCategory(plugin, rule),
343-
};
344-
});
382+
return allDiagnostics;
345383
} finally {
346384
restoreDisableDirectives();
347385
if (fs.existsSync(configPath)) {

0 commit comments

Comments
 (0)