react-doctor 0.0.42 → 0.0.45

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,63 +1,64 @@
1
- #!/usr/bin/env node
2
1
  import { createRequire } from "node:module";
3
- import fs, { accessSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import fs, { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
3
  import os, { tmpdir } from "node:os";
5
4
  import path, { join } from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
6
  import { Command } from "commander";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { SKILL_MANIFEST_FILE, detectInstalledSkillAgents, getSkillAgentConfig, getSkillAgentTypes, installSkillsFromSource } from "agent-install";
8
9
  import pc from "picocolors";
9
10
  import basePrompts from "prompts";
10
11
  import ora from "ora";
11
12
  import { randomUUID } from "node:crypto";
12
- import { performance } from "node:perf_hooks";
13
- import { execSync, spawn, spawnSync } from "node:child_process";
13
+ import { spawn, spawnSync } from "node:child_process";
14
14
  import { main } from "knip";
15
15
  import { createOptions } from "knip/session";
16
+ //#region src/constants.ts
17
+ const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
18
+ const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
19
+ const MILLISECONDS_PER_SECOND = 1e3;
20
+ const SCORE_API_URL = "https://www.react.doctor/api/score";
21
+ const SHARE_BASE_URL = "https://www.react.doctor/share";
22
+ const FETCH_TIMEOUT_MS = 1e4;
23
+ const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
24
+ const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
25
+ const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
26
+ const ERROR_RULE_PENALTY = 1.5;
27
+ const WARNING_RULE_PENALTY = .75;
28
+ const KNIP_CONFIG_LOCATIONS = [
29
+ "knip.json",
30
+ "knip.jsonc",
31
+ ".knip.json",
32
+ ".knip.jsonc",
33
+ "knip.ts",
34
+ "knip.js",
35
+ "knip.config.ts",
36
+ "knip.config.js"
37
+ ];
38
+ const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
39
+ const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
40
+ const IGNORED_DIRECTORIES = new Set([
41
+ "node_modules",
42
+ "dist",
43
+ "build",
44
+ "coverage"
45
+ ]);
46
+ const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
47
+ const SKILL_NAME = "react-doctor";
48
+ const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
49
+ const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
50
+ //#endregion
16
51
  //#region src/utils/detect-agents.ts
17
- const AGENTS_SKILL_DIR = ".agents/skills";
18
- const SUPPORTED_AGENTS = {
19
- claude: {
20
- binaries: ["claude"],
21
- displayName: "Claude Code",
22
- skillDir: ".claude/skills"
23
- },
24
- codex: {
25
- binaries: ["codex"],
26
- displayName: "Codex",
27
- skillDir: AGENTS_SKILL_DIR
28
- },
29
- copilot: {
30
- binaries: ["copilot"],
31
- displayName: "GitHub Copilot",
32
- skillDir: AGENTS_SKILL_DIR
33
- },
34
- gemini: {
35
- binaries: ["gemini"],
36
- displayName: "Gemini CLI",
37
- skillDir: AGENTS_SKILL_DIR
38
- },
39
- cursor: {
40
- binaries: ["cursor", "agent"],
41
- displayName: "Cursor",
42
- skillDir: AGENTS_SKILL_DIR
43
- },
44
- opencode: {
45
- binaries: ["opencode"],
46
- displayName: "OpenCode",
47
- skillDir: AGENTS_SKILL_DIR
48
- },
49
- droid: {
50
- binaries: ["droid"],
51
- displayName: "Factory Droid",
52
- skillDir: ".factory/skills"
53
- },
54
- pi: {
55
- binaries: ["pi", "omegon"],
56
- displayName: "Pi",
57
- skillDir: AGENTS_SKILL_DIR
58
- }
52
+ const PATH_BINARIES = {
53
+ "claude-code": ["claude"],
54
+ codex: ["codex"],
55
+ cursor: ["cursor", "agent"],
56
+ droid: ["droid"],
57
+ "gemini-cli": ["gemini"],
58
+ "github-copilot": ["copilot"],
59
+ opencode: ["opencode"],
60
+ pi: ["pi", "omegon"]
59
61
  };
60
- const ALL_SUPPORTED_AGENTS = Object.keys(SUPPORTED_AGENTS);
61
62
  const isCommandAvailable = (command) => {
62
63
  const pathDirectories = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
63
64
  for (const directory of pathDirectories) {
@@ -71,9 +72,15 @@ const isCommandAvailable = (command) => {
71
72
  }
72
73
  return false;
73
74
  };
74
- const detectAvailableAgents = () => ALL_SUPPORTED_AGENTS.filter((agent) => SUPPORTED_AGENTS[agent].binaries.some(isCommandAvailable));
75
- const toDisplayName = (agent) => SUPPORTED_AGENTS[agent].displayName;
76
- const toSkillDir = (agent) => SUPPORTED_AGENTS[agent].skillDir;
75
+ const detectPathAvailableAgents = () => {
76
+ const detected = [];
77
+ for (const [agent, binaries] of Object.entries(PATH_BINARIES)) if (binaries.some(isCommandAvailable)) detected.push(agent);
78
+ return detected;
79
+ };
80
+ const detectAvailableAgents = async () => {
81
+ const detected = new Set([...detectPathAvailableAgents(), ...await detectInstalledSkillAgents()]);
82
+ return getSkillAgentTypes().filter((agent) => agent !== "universal" && detected.has(agent));
83
+ };
77
84
  //#endregion
78
85
  //#region src/utils/highlighter.ts
79
86
  const highlighter = {
@@ -84,40 +91,39 @@ const highlighter = {
84
91
  dim: pc.dim
85
92
  };
86
93
  //#endregion
87
- //#region src/utils/install-skill-for-agent.ts
88
- const installSkillForAgent = (projectRoot, agent, skillSourceDirectory, skillName, alreadyInstalledDirectories) => {
89
- const installedSkillDirectory = path.join(projectRoot, toSkillDir(agent), skillName);
90
- if (alreadyInstalledDirectories?.has(installedSkillDirectory)) return installedSkillDirectory;
91
- rmSync(installedSkillDirectory, {
92
- recursive: true,
93
- force: true
94
- });
95
- mkdirSync(path.dirname(installedSkillDirectory), { recursive: true });
96
- cpSync(skillSourceDirectory, installedSkillDirectory, { recursive: true });
97
- return installedSkillDirectory;
98
- };
99
- //#endregion
100
94
  //#region src/utils/logger.ts
95
+ let isSilent$1 = false;
96
+ const setLoggerSilent = (silent) => {
97
+ isSilent$1 = silent;
98
+ };
99
+ const isLoggerSilent = () => isSilent$1;
101
100
  const logger = {
102
101
  error(...args) {
103
- console.log(highlighter.error(args.join(" ")));
102
+ if (isSilent$1) return;
103
+ console.error(highlighter.error(args.join(" ")));
104
104
  },
105
105
  warn(...args) {
106
- console.log(highlighter.warn(args.join(" ")));
106
+ if (isSilent$1) return;
107
+ console.warn(highlighter.warn(args.join(" ")));
107
108
  },
108
109
  info(...args) {
110
+ if (isSilent$1) return;
109
111
  console.log(highlighter.info(args.join(" ")));
110
112
  },
111
113
  success(...args) {
114
+ if (isSilent$1) return;
112
115
  console.log(highlighter.success(args.join(" ")));
113
116
  },
114
117
  dim(...args) {
118
+ if (isSilent$1) return;
115
119
  console.log(highlighter.dim(args.join(" ")));
116
120
  },
117
121
  log(...args) {
122
+ if (isSilent$1) return;
118
123
  console.log(args.join(" "));
119
124
  },
120
125
  break() {
126
+ if (isSilent$1) return;
121
127
  console.log("");
122
128
  }
123
129
  };
@@ -137,15 +143,11 @@ const shouldSelectAllChoices = (choiceStates) => {
137
143
  //#region src/utils/prompts.ts
138
144
  const require = createRequire(import.meta.url);
139
145
  const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
140
- const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
141
146
  let didPatchMultiselectToggleAll = false;
142
147
  let didPatchMultiselectSubmit = false;
143
- let didPatchSelectBanner = false;
144
- const selectBannerMap = /* @__PURE__ */ new Map();
145
148
  const onCancel = () => {
146
149
  logger.break();
147
150
  logger.log("Cancelled.");
148
- logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
149
151
  logger.break();
150
152
  process.exit(0);
151
153
  };
@@ -177,25 +179,9 @@ const patchMultiselectSubmit = () => {
177
179
  originalSubmit.call(this);
178
180
  };
179
181
  };
180
- const patchSelectBanner = () => {
181
- if (didPatchSelectBanner) return;
182
- didPatchSelectBanner = true;
183
- const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
184
- const promptsClear = require("prompts/lib/util/clear");
185
- const originalRender = selectConstructor.prototype.render;
186
- selectConstructor.prototype.render = function() {
187
- originalRender.call(this);
188
- const banner = selectBannerMap.get(this.cursor);
189
- if (!banner || this.closed || this.done) return;
190
- this.out.write(promptsClear(this.outputText, this.out.columns));
191
- this.outputText = `${banner}\n\n${this.outputText}`;
192
- this.out.write(this.outputText);
193
- };
194
- };
195
182
  const prompts = (questions) => {
196
183
  patchMultiselectToggleAll();
197
184
  patchMultiselectSubmit();
198
- patchSelectBanner();
199
185
  return basePrompts(questions, { onCancel });
200
186
  };
201
187
  //#endregion
@@ -203,10 +189,20 @@ const prompts = (questions) => {
203
189
  let sharedInstance = null;
204
190
  let activeCount = 0;
205
191
  const pendingTexts = /* @__PURE__ */ new Set();
192
+ const finalizedHandles = /* @__PURE__ */ new WeakSet();
193
+ let isSilent = false;
194
+ const setSpinnerSilent = (silent) => {
195
+ isSilent = silent;
196
+ };
197
+ const isSpinnerSilent = () => isSilent;
198
+ const noopHandle = Object.freeze({
199
+ succeed: () => {},
200
+ fail: () => {}
201
+ });
206
202
  const finalize = (method, originalText, displayText) => {
207
203
  pendingTexts.delete(originalText);
208
- activeCount--;
209
- if (activeCount <= 0 || !sharedInstance) {
204
+ activeCount = Math.max(0, activeCount - 1);
205
+ if (activeCount === 0 || !sharedInstance) {
210
206
  sharedInstance?.[method](displayText);
211
207
  sharedInstance = null;
212
208
  activeCount = 0;
@@ -219,41 +215,54 @@ const finalize = (method, originalText, displayText) => {
219
215
  sharedInstance.start();
220
216
  };
221
217
  const spinner = (text) => ({ start() {
218
+ if (isSilent) return noopHandle;
222
219
  activeCount++;
223
220
  pendingTexts.add(text);
224
221
  if (!sharedInstance) sharedInstance = ora({ text }).start();
225
222
  else sharedInstance.text = text;
226
- return {
227
- succeed: (displayText) => finalize("succeed", text, displayText),
228
- fail: (displayText) => finalize("fail", text, displayText)
223
+ const handle = {
224
+ succeed: (displayText) => {
225
+ if (finalizedHandles.has(handle)) return;
226
+ finalizedHandles.add(handle);
227
+ finalize("succeed", text, displayText);
228
+ },
229
+ fail: (displayText) => {
230
+ if (finalizedHandles.has(handle)) return;
231
+ finalizedHandles.add(handle);
232
+ finalize("fail", text, displayText);
233
+ }
229
234
  };
235
+ return handle;
230
236
  } });
231
237
  //#endregion
238
+ //#region src/utils/to-display-name.ts
239
+ const toDisplayName = (agent) => getSkillAgentConfig(agent).displayName;
240
+ //#endregion
232
241
  //#region src/install-skill.ts
233
- const SKILL_NAME = "react-doctor";
234
242
  const getSkillSourceDirectory = () => {
235
243
  const distDirectory = path.dirname(fileURLToPath(import.meta.url));
236
244
  return path.join(distDirectory, "skills", SKILL_NAME);
237
245
  };
238
246
  const runInstallSkill = async (options = {}) => {
239
- const projectRoot = process.cwd();
240
- const sourceDir = getSkillSourceDirectory();
241
- if (!existsSync(path.join(sourceDir, "SKILL.md"))) {
247
+ const projectRoot = options.projectRoot ?? process.cwd();
248
+ const sourceDir = options.sourceDir ?? getSkillSourceDirectory();
249
+ if (!existsSync(path.join(sourceDir, SKILL_MANIFEST_FILE))) {
242
250
  logger.error(`Could not locate the ${SKILL_NAME} skill bundled with this package.`);
243
251
  process.exitCode = 1;
244
252
  return;
245
253
  }
246
- const detectedAgents = detectAvailableAgents();
254
+ const detectedAgents = options.detectedAgents ?? await detectAvailableAgents();
247
255
  if (detectedAgents.length === 0) {
248
- logger.error("No supported coding agents detected on your PATH.");
249
- logger.dim(" Supported: Claude Code, Codex, GitHub Copilot, Gemini CLI, Cursor, OpenCode, Factory Droid, Pi.");
256
+ logger.error("No supported coding agents detected.");
257
+ logger.dim(" Looked for binaries on PATH (claude, codex, cursor, droid, gemini, copilot, opencode, pi)");
258
+ logger.dim(" and config dirs in $HOME (~/.claude, ~/.cursor, ~/.codex, ~/.gemini, ...).");
250
259
  process.exitCode = 1;
251
260
  return;
252
261
  }
253
262
  const selectedAgents = Boolean(options.yes) || !process.stdin.isTTY ? detectedAgents : (await prompts({
254
263
  type: "multiselect",
255
264
  name: "agents",
256
- message: `Install the ${highlighter.info(SKILL_NAME)} skill for:`,
265
+ message: `Install the ${highlighter.info("react-doctor")} skill for:`,
257
266
  choices: detectedAgents.map((agent) => ({
258
267
  title: toDisplayName(agent),
259
268
  value: agent,
@@ -263,46 +272,29 @@ const runInstallSkill = async (options = {}) => {
263
272
  min: 1
264
273
  })).agents ?? [];
265
274
  if (selectedAgents.length === 0) return;
275
+ if (options.dryRun) {
276
+ logger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
277
+ for (const agent of selectedAgents) logger.dim(` - ${toDisplayName(agent)}`);
278
+ logger.dim(` Source: ${sourceDir}`);
279
+ return;
280
+ }
266
281
  const installSpinner = spinner(`Installing ${SKILL_NAME} skill...`).start();
267
- const installedDirectories = /* @__PURE__ */ new Set();
268
- for (const agent of selectedAgents) {
269
- const installedDirectory = installSkillForAgent(projectRoot, agent, sourceDir, SKILL_NAME, installedDirectories);
270
- installedDirectories.add(installedDirectory);
282
+ try {
283
+ const installResult = await installSkillsFromSource({
284
+ source: sourceDir,
285
+ agents: selectedAgents,
286
+ cwd: projectRoot,
287
+ mode: "copy"
288
+ });
289
+ if (installResult.skills.length === 0) throw new Error(`Could not parse ${SKILL_MANIFEST_FILE} for ${SKILL_NAME} (missing or invalid frontmatter).`);
290
+ if (installResult.failed.length > 0) throw new Error(installResult.failed.map((failure) => `${toDisplayName(failure.agent)}: ${failure.error}`).join("\n"));
291
+ installSpinner.succeed(`${SKILL_NAME} skill installed for ${selectedAgents.map(toDisplayName).join(", ")}.`);
292
+ } catch (error) {
293
+ installSpinner.fail(`Failed to install ${SKILL_NAME} skill.`);
294
+ throw error;
271
295
  }
272
- installSpinner.succeed(`${SKILL_NAME} skill installed for ${selectedAgents.map(toDisplayName).join(", ")}.`);
273
296
  };
274
297
  //#endregion
275
- //#region src/constants.ts
276
- const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
277
- const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
278
- const MILLISECONDS_PER_SECOND = 1e3;
279
- const SCORE_API_URL = "https://www.react.doctor/api/score";
280
- const SHARE_BASE_URL = "https://www.react.doctor/share";
281
- const FETCH_TIMEOUT_MS = 1e4;
282
- const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
283
- const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
284
- const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
285
- const ERROR_RULE_PENALTY = 1.5;
286
- const WARNING_RULE_PENALTY = .75;
287
- const KNIP_CONFIG_LOCATIONS = [
288
- "knip.json",
289
- "knip.jsonc",
290
- ".knip.json",
291
- ".knip.jsonc",
292
- "knip.ts",
293
- "knip.js",
294
- "knip.config.ts",
295
- "knip.config.js"
296
- ];
297
- const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
298
- const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
299
- const IGNORED_DIRECTORIES = new Set([
300
- "node_modules",
301
- "dist",
302
- "build",
303
- "coverage"
304
- ]);
305
- //#endregion
306
298
  //#region src/core/calculate-score-locally.ts
307
299
  const getScoreLabel = (score) => {
308
300
  if (score >= 75) return "Great";
@@ -347,43 +339,51 @@ const parseScoreResult = (value) => {
347
339
  label: labelValue
348
340
  };
349
341
  };
342
+ const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
343
+ const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
344
+ const describeFailure = (error) => {
345
+ if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
346
+ if (error instanceof Error && error.message) return error.message;
347
+ return String(error);
348
+ };
350
349
  const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
350
+ if (typeof fetchImplementation !== "function") return null;
351
351
  const controller = new AbortController();
352
352
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
353
353
  try {
354
354
  const response = await fetchImplementation(SCORE_API_URL, {
355
355
  method: "POST",
356
356
  headers: { "Content-Type": "application/json" },
357
- body: JSON.stringify({ diagnostics }),
357
+ body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
358
358
  signal: controller.signal
359
359
  });
360
- if (!response.ok) return null;
360
+ if (!response.ok) {
361
+ console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
362
+ return null;
363
+ }
361
364
  return parseScoreResult(await response.json());
362
- } catch {
365
+ } catch (error) {
366
+ console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
363
367
  return null;
364
368
  } finally {
365
369
  clearTimeout(timeoutId);
366
370
  }
367
371
  };
368
372
  //#endregion
373
+ //#region src/utils/calculate-score-browser.ts
374
+ const getGlobalFetch = () => typeof fetch === "function" ? fetch : void 0;
375
+ const calculateScore$1 = async (diagnostics, fetchImplementation = getGlobalFetch()) => await tryScoreFromApi(diagnostics, fetchImplementation) ?? calculateScoreLocally(diagnostics);
376
+ //#endregion
369
377
  //#region src/utils/proxy-fetch.ts
370
378
  const getGlobalProcess = () => {
371
379
  const candidate = globalThis.process;
372
380
  return candidate?.versions?.node ? candidate : void 0;
373
381
  };
374
- const readEnvProxy = () => {
382
+ const getProxyUrl = () => {
375
383
  const proc = getGlobalProcess();
376
384
  if (!proc?.env) return void 0;
377
385
  return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
378
386
  };
379
- let isProxyUrlResolved = false;
380
- let resolvedProxyUrl;
381
- const getProxyUrl = () => {
382
- if (isProxyUrlResolved) return resolvedProxyUrl;
383
- isProxyUrlResolved = true;
384
- resolvedProxyUrl = readEnvProxy();
385
- return resolvedProxyUrl;
386
- };
387
387
  const createProxyDispatcher = async (proxyUrl) => {
388
388
  try {
389
389
  const { ProxyAgent } = await import("undici");
@@ -393,27 +393,17 @@ const createProxyDispatcher = async (proxyUrl) => {
393
393
  }
394
394
  };
395
395
  const proxyFetch = async (url, init) => {
396
- const controller = new AbortController();
397
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
398
- try {
399
- const proxyUrl = getProxyUrl();
400
- const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
401
- return await fetch(url, {
402
- ...init,
403
- signal: controller.signal,
404
- ...dispatcher ? { dispatcher } : {}
405
- });
406
- } finally {
407
- clearTimeout(timeoutId);
408
- }
396
+ const proxyUrl = getProxyUrl();
397
+ const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
398
+ const fetchInit = {
399
+ ...init,
400
+ ...dispatcher ? { dispatcher } : {}
401
+ };
402
+ return fetch(url, fetchInit);
409
403
  };
410
404
  //#endregion
411
405
  //#region src/utils/calculate-score-node.ts
412
- const calculateScore = async (diagnostics) => {
413
- const apiScore = await tryScoreFromApi(diagnostics, proxyFetch);
414
- if (apiScore) return apiScore;
415
- return calculateScoreLocally(diagnostics);
416
- };
406
+ const calculateScore = (diagnostics) => calculateScore$1(diagnostics, proxyFetch);
417
407
  //#endregion
418
408
  //#region src/utils/colorize-by-score.ts
419
409
  const colorizeByScore = (text, score) => {
@@ -435,7 +425,8 @@ const isFile = (filePath) => {
435
425
  };
436
426
  //#endregion
437
427
  //#region src/utils/read-package-json.ts
438
- const readPackageJson = (packageJsonPath) => {
428
+ const cachedPackageJsons = /* @__PURE__ */ new Map();
429
+ const readPackageJsonUncached = (packageJsonPath) => {
439
430
  try {
440
431
  return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
441
432
  } catch (error) {
@@ -447,10 +438,25 @@ const readPackageJson = (packageJsonPath) => {
447
438
  throw error;
448
439
  }
449
440
  };
441
+ const readPackageJson = (packageJsonPath) => {
442
+ const absolutePath = path.resolve(packageJsonPath);
443
+ const cached = cachedPackageJsons.get(absolutePath);
444
+ if (cached !== void 0) return cached;
445
+ const result = readPackageJsonUncached(absolutePath);
446
+ cachedPackageJsons.set(absolutePath, result);
447
+ return result;
448
+ };
450
449
  //#endregion
451
450
  //#region src/utils/check-reduced-motion.ts
452
451
  const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
453
- const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
452
+ const REDUCED_MOTION_FILE_GLOBS = [
453
+ "*.ts",
454
+ "*.tsx",
455
+ "*.js",
456
+ "*.jsx",
457
+ "*.css",
458
+ "*.scss"
459
+ ];
454
460
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
455
461
  filePath: "package.json",
456
462
  plugin: "react-doctor",
@@ -460,8 +466,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
460
466
  help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
461
467
  line: 0,
462
468
  column: 0,
463
- category: "Accessibility",
464
- weight: 2
469
+ category: "Accessibility"
465
470
  };
466
471
  const checkReducedMotion = (rootDirectory) => {
467
472
  const packageJsonPath = path.join(rootDirectory, "package.json");
@@ -478,15 +483,24 @@ const checkReducedMotion = (rootDirectory) => {
478
483
  return [];
479
484
  }
480
485
  if (!hasMotionLibrary) return [];
481
- try {
482
- execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
483
- cwd: rootDirectory,
484
- stdio: "pipe"
485
- });
486
- return [];
487
- } catch {
488
- return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
489
- }
486
+ const result = spawnSync("git", [
487
+ "grep",
488
+ "-ql",
489
+ "-E",
490
+ REDUCED_MOTION_GREP_PATTERN,
491
+ "--",
492
+ ...REDUCED_MOTION_FILE_GLOBS
493
+ ], {
494
+ cwd: rootDirectory,
495
+ stdio: [
496
+ "ignore",
497
+ "pipe",
498
+ "pipe"
499
+ ]
500
+ });
501
+ if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
502
+ if (result.status === 0) return [];
503
+ return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
490
504
  };
491
505
  //#endregion
492
506
  //#region src/utils/read-file-lines-node.ts
@@ -535,7 +549,11 @@ const toRelativePath = (filePath, rootDirectory) => {
535
549
  if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
536
550
  return normalizedFilePath.replace(/^\.\//, "");
537
551
  };
538
- const compileIgnoredFilePatterns = (userConfig) => Array.isArray(userConfig?.ignore?.files) ? userConfig.ignore.files.map(compileGlobPattern) : [];
552
+ const compileIgnoredFilePatterns = (userConfig) => {
553
+ const files = userConfig?.ignore?.files;
554
+ if (!Array.isArray(files)) return [];
555
+ return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
556
+ };
539
557
  const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
540
558
  if (patterns.length === 0) return false;
541
559
  const relativePath = toRelativePath(filePath, rootDirectory);
@@ -576,9 +594,9 @@ const isRuleSuppressed = (commentRules, ruleId) => {
576
594
  return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
577
595
  };
578
596
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
579
- const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
597
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
580
598
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
581
- const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents : []);
599
+ const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
582
600
  const hasTextComponents = textComponentNames.size > 0;
583
601
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
584
602
  return diagnostics.filter((diagnostic) => {
@@ -620,11 +638,9 @@ const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, rea
620
638
  return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
621
639
  };
622
640
  //#endregion
623
- //#region src/utils/jsx-include-paths.ts
624
- const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
625
- //#endregion
626
641
  //#region src/utils/combine-diagnostics.ts
627
- const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true) => {
642
+ const combineDiagnostics = (input) => {
643
+ const { lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true } = input;
628
644
  const extraDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
629
645
  return mergeAndFilterDiagnostics([
630
646
  ...lintDiagnostics,
@@ -633,6 +649,9 @@ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isD
633
649
  ], directory, userConfig, readFileLinesSync);
634
650
  };
635
651
  //#endregion
652
+ //#region src/utils/jsx-include-paths.ts
653
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
654
+ //#endregion
636
655
  //#region src/utils/find-monorepo-root.ts
637
656
  const isMonorepoRoot = (directory) => {
638
657
  if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
@@ -652,7 +671,11 @@ const findMonorepoRoot = (startDirectory) => {
652
671
  };
653
672
  //#endregion
654
673
  //#region src/utils/is-plain-object.ts
655
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
674
+ const isPlainObject = (value) => {
675
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
676
+ const prototype = Object.getPrototypeOf(value);
677
+ return prototype === null || prototype === Object.prototype;
678
+ };
656
679
  //#endregion
657
680
  //#region src/utils/discover-project.ts
658
681
  const REACT_COMPILER_PACKAGES = new Set([
@@ -660,6 +683,11 @@ const REACT_COMPILER_PACKAGES = new Set([
660
683
  "react-compiler-runtime",
661
684
  "eslint-plugin-react-compiler"
662
685
  ]);
686
+ const TANSTACK_QUERY_PACKAGES = new Set([
687
+ "@tanstack/react-query",
688
+ "@tanstack/query-core",
689
+ "react-query"
690
+ ]);
663
691
  const NEXT_CONFIG_FILENAMES = [
664
692
  "next.config.js",
665
693
  "next.config.mjs",
@@ -678,7 +706,11 @@ const VITE_CONFIG_FILENAMES = [
678
706
  "vite.config.js",
679
707
  "vite.config.ts",
680
708
  "vite.config.mjs",
681
- "vite.config.cjs"
709
+ "vite.config.mts",
710
+ "vite.config.cjs",
711
+ "vite.config.cts",
712
+ "vitest.config.ts",
713
+ "vitest.config.js"
682
714
  ];
683
715
  const EXPO_APP_CONFIG_FILENAMES = [
684
716
  "app.json",
@@ -686,7 +718,7 @@ const EXPO_APP_CONFIG_FILENAMES = [
686
718
  "app.config.ts"
687
719
  ];
688
720
  const REACT_COMPILER_PACKAGE_REFERENCE_PATTERN = /babel-plugin-react-compiler|react-compiler-runtime|eslint-plugin-react-compiler|["']react-compiler["']/;
689
- const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*true\b/;
721
+ const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*(?:true\b|\{)/;
690
722
  const FRAMEWORK_PACKAGES = {
691
723
  next: "nextjs",
692
724
  "@tanstack/react-start": "tanstack-start",
@@ -728,6 +760,7 @@ const countSourceFilesViaFilesystem = (rootDirectory) => {
728
760
  const countSourceFilesViaGit = (rootDirectory) => {
729
761
  const result = spawnSync("git", [
730
762
  "ls-files",
763
+ "-z",
731
764
  "--cached",
732
765
  "--others",
733
766
  "--exclude-standard"
@@ -737,7 +770,7 @@ const countSourceFilesViaGit = (rootDirectory) => {
737
770
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
738
771
  });
739
772
  if (result.error || result.status !== 0) return null;
740
- return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
773
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
741
774
  };
742
775
  const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
743
776
  const collectAllDependencies = (packageJson) => ({
@@ -835,17 +868,17 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
835
868
  const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
836
869
  const rawVersion = collectAllDependencies(packageJson)[packageName];
837
870
  const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
838
- const raw = packageJson;
839
- if (isPlainObject(raw.catalog)) {
840
- const version = resolveVersionFromCatalog(raw.catalog, packageName);
871
+ if (isPlainObject(packageJson.catalog)) {
872
+ const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
841
873
  if (version) return version;
842
874
  }
843
- if (isPlainObject(raw.catalogs)) {
844
- if (catalogName && isPlainObject(raw.catalogs[catalogName])) {
845
- const version = resolveVersionFromCatalog(raw.catalogs[catalogName], packageName);
875
+ if (isPlainObject(packageJson.catalogs)) {
876
+ const namedCatalog = catalogName ? packageJson.catalogs[catalogName] : void 0;
877
+ if (namedCatalog && isPlainObject(namedCatalog)) {
878
+ const version = resolveVersionFromCatalog(namedCatalog, packageName);
846
879
  if (version) return version;
847
880
  }
848
- for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
881
+ for (const catalogEntries of Object.values(packageJson.catalogs)) if (isPlainObject(catalogEntries)) {
849
882
  const version = resolveVersionFromCatalog(catalogEntries, packageName);
850
883
  if (version) return version;
851
884
  }
@@ -886,11 +919,32 @@ const parsePnpmWorkspacePatterns = (rootDirectory) => {
886
919
  }
887
920
  return patterns;
888
921
  };
922
+ const NX_PROJECT_DISCOVERY_DIRS = [
923
+ "apps",
924
+ "libs",
925
+ "packages"
926
+ ];
927
+ const getNxWorkspaceDirectories = (rootDirectory) => {
928
+ if (!isFile(path.join(rootDirectory, "nx.json"))) return [];
929
+ const collected = [];
930
+ for (const candidate of NX_PROJECT_DISCOVERY_DIRS) {
931
+ const candidatePath = path.join(rootDirectory, candidate);
932
+ if (!fs.existsSync(candidatePath) || !fs.statSync(candidatePath).isDirectory()) continue;
933
+ for (const entry of fs.readdirSync(candidatePath, { withFileTypes: true })) {
934
+ if (!entry.isDirectory()) continue;
935
+ const projectDirectory = path.join(candidatePath, entry.name);
936
+ if (isFile(path.join(projectDirectory, "project.json")) || isFile(path.join(projectDirectory, "package.json"))) collected.push(`${candidate}/${entry.name}`);
937
+ }
938
+ }
939
+ return collected;
940
+ };
889
941
  const getWorkspacePatterns = (rootDirectory, packageJson) => {
890
942
  const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory);
891
943
  if (pnpmPatterns.length > 0) return pnpmPatterns;
892
944
  if (Array.isArray(packageJson.workspaces)) return packageJson.workspaces;
893
945
  if (packageJson.workspaces?.packages) return packageJson.workspaces.packages;
946
+ const nxPatterns = getNxWorkspaceDirectories(rootDirectory);
947
+ if (nxPatterns.length > 0) return nxPatterns;
894
948
  return [];
895
949
  };
896
950
  const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
@@ -1020,23 +1074,32 @@ const hasCompilerInConfigFile = (filePath) => {
1020
1074
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
1021
1075
  };
1022
1076
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
1077
+ const isProjectBoundary$1 = (directory) => {
1078
+ if (fs.existsSync(path.join(directory, ".git"))) return true;
1079
+ return isMonorepoRoot(directory);
1080
+ };
1023
1081
  const detectReactCompiler = (directory, packageJson) => {
1024
1082
  if (hasCompilerPackage(packageJson)) return true;
1025
1083
  if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
1026
1084
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
1027
1085
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
1028
1086
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
1087
+ if (isProjectBoundary$1(directory)) return false;
1029
1088
  let ancestorDirectory = path.dirname(directory);
1030
1089
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1031
1090
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
1032
1091
  if (isFile(ancestorPackagePath)) {
1033
1092
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
1034
1093
  }
1094
+ if (isProjectBoundary$1(ancestorDirectory)) return false;
1035
1095
  ancestorDirectory = path.dirname(ancestorDirectory);
1036
1096
  }
1037
1097
  return false;
1038
1098
  };
1099
+ const cachedProjectInfos = /* @__PURE__ */ new Map();
1039
1100
  const discoverProject = (directory) => {
1101
+ const cached = cachedProjectInfos.get(directory);
1102
+ if (cached !== void 0) return cached;
1040
1103
  const packageJsonPath = path.join(directory, "package.json");
1041
1104
  if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
1042
1105
  const packageJson = readPackageJson(packageJsonPath);
@@ -1063,15 +1126,20 @@ const discoverProject = (directory) => {
1063
1126
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
1064
1127
  const sourceFileCount = countSourceFiles(directory);
1065
1128
  const hasReactCompiler = detectReactCompiler(directory, packageJson);
1066
- return {
1129
+ const allDependencies = collectAllDependencies(packageJson);
1130
+ const hasTanStackQuery = Object.keys(allDependencies).some((packageName) => TANSTACK_QUERY_PACKAGES.has(packageName));
1131
+ const projectInfo = {
1067
1132
  rootDirectory: directory,
1068
1133
  projectName,
1069
1134
  reactVersion,
1070
1135
  framework,
1071
1136
  hasTypeScript,
1072
1137
  hasReactCompiler,
1138
+ hasTanStackQuery,
1073
1139
  sourceFileCount
1074
1140
  };
1141
+ cachedProjectInfos.set(directory, projectInfo);
1142
+ return projectInfo;
1075
1143
  };
1076
1144
  //#endregion
1077
1145
  //#region src/utils/format-error-chain.ts
@@ -1131,6 +1199,42 @@ const groupBy = (items, keyFn) => {
1131
1199
  //#region src/utils/indent-multiline-text.ts
1132
1200
  const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
1133
1201
  //#endregion
1202
+ //#region src/utils/validate-config-types.ts
1203
+ const BOOLEAN_FIELD_NAMES = [
1204
+ "lint",
1205
+ "deadCode",
1206
+ "verbose",
1207
+ "customRulesOnly",
1208
+ "share",
1209
+ "respectInlineDisables"
1210
+ ];
1211
+ const warnConfigField = (message) => {
1212
+ process.stderr.write(`[react-doctor] ${message}\n`);
1213
+ };
1214
+ const coerceMaybeBooleanString = (fieldName, value) => {
1215
+ if (typeof value === "boolean" || value === void 0) return value;
1216
+ if (value === "true") {
1217
+ warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
1218
+ return true;
1219
+ }
1220
+ if (value === "false") {
1221
+ warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
1222
+ return false;
1223
+ }
1224
+ warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1225
+ };
1226
+ const validateConfigTypes = (config) => {
1227
+ const validated = { ...config };
1228
+ for (const fieldName of BOOLEAN_FIELD_NAMES) {
1229
+ const original = config[fieldName];
1230
+ if (original === void 0) continue;
1231
+ const coerced = coerceMaybeBooleanString(fieldName, original);
1232
+ if (coerced === void 0) delete validated[fieldName];
1233
+ else validated[fieldName] = coerced;
1234
+ }
1235
+ return validated;
1236
+ };
1237
+ //#endregion
1134
1238
  //#region src/utils/load-config.ts
1135
1239
  const CONFIG_FILENAME = "react-doctor.config.json";
1136
1240
  const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
@@ -1139,30 +1243,52 @@ const loadConfigFromDirectory = (directory) => {
1139
1243
  if (isFile(configFilePath)) try {
1140
1244
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
1141
1245
  const parsed = JSON.parse(fileContent);
1142
- if (isPlainObject(parsed)) return parsed;
1143
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
1246
+ if (isPlainObject(parsed)) return validateConfigTypes(parsed);
1247
+ logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
1144
1248
  } catch (error) {
1145
- console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
1249
+ logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
1146
1250
  }
1147
1251
  const packageJsonPath = path.join(directory, "package.json");
1148
1252
  if (isFile(packageJsonPath)) try {
1149
1253
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
1150
- const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
1151
- if (isPlainObject(embeddedConfig)) return embeddedConfig;
1254
+ const packageJson = JSON.parse(fileContent);
1255
+ if (isPlainObject(packageJson)) {
1256
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
1257
+ if (isPlainObject(embeddedConfig)) return validateConfigTypes(embeddedConfig);
1258
+ }
1152
1259
  } catch {
1153
1260
  return null;
1154
1261
  }
1155
1262
  return null;
1156
1263
  };
1264
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1265
+ const cachedConfigs = /* @__PURE__ */ new Map();
1157
1266
  const loadConfig = (rootDirectory) => {
1267
+ const cached = cachedConfigs.get(rootDirectory);
1268
+ if (cached !== void 0) return cached;
1158
1269
  const localConfig = loadConfigFromDirectory(rootDirectory);
1159
- if (localConfig) return localConfig;
1270
+ if (localConfig) {
1271
+ cachedConfigs.set(rootDirectory, localConfig);
1272
+ return localConfig;
1273
+ }
1274
+ if (isProjectBoundary(rootDirectory)) {
1275
+ cachedConfigs.set(rootDirectory, null);
1276
+ return null;
1277
+ }
1160
1278
  let ancestorDirectory = path.dirname(rootDirectory);
1161
1279
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1162
1280
  const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
1163
- if (ancestorConfig) return ancestorConfig;
1281
+ if (ancestorConfig) {
1282
+ cachedConfigs.set(rootDirectory, ancestorConfig);
1283
+ return ancestorConfig;
1284
+ }
1285
+ if (isProjectBoundary(ancestorDirectory)) {
1286
+ cachedConfigs.set(rootDirectory, null);
1287
+ return null;
1288
+ }
1164
1289
  ancestorDirectory = path.dirname(ancestorDirectory);
1165
1290
  }
1291
+ cachedConfigs.set(rootDirectory, null);
1166
1292
  return null;
1167
1293
  };
1168
1294
  //#endregion
@@ -1205,23 +1331,25 @@ const findCompatibleNvmBinary = () => {
1205
1331
  return existsSync(binaryPath) ? binaryPath : null;
1206
1332
  };
1207
1333
  const getNodeVersionFromBinary = (binaryPath) => {
1208
- try {
1209
- return execSync(`"${binaryPath}" --version`, { encoding: "utf-8" }).trim();
1210
- } catch {
1211
- return null;
1212
- }
1334
+ const result = spawnSync(binaryPath, ["--version"], { encoding: "utf-8" });
1335
+ if (result.error || result.status !== 0) return null;
1336
+ return result.stdout.toString().trim();
1213
1337
  };
1214
1338
  const installNodeViaNvm = () => {
1215
1339
  const nvmDirectory = getNvmDirectory();
1216
1340
  if (!nvmDirectory) return false;
1217
1341
  const nvmScript = path.join(nvmDirectory, "nvm.sh");
1218
1342
  if (!existsSync(nvmScript)) return false;
1219
- try {
1220
- execSync(`bash -c ". '${nvmScript}' && nvm install 24"`, { stdio: "inherit" });
1221
- return findCompatibleNvmBinary() !== null;
1222
- } catch {
1223
- return false;
1224
- }
1343
+ const result = spawnSync("bash", ["-c", ". \"$NVM_SCRIPT\" && nvm install \"$NODE_MAJOR\""], {
1344
+ stdio: "inherit",
1345
+ env: {
1346
+ ...process.env,
1347
+ NVM_SCRIPT: nvmScript,
1348
+ NODE_MAJOR: String(24)
1349
+ }
1350
+ });
1351
+ if (result.error || result.status !== 0) return false;
1352
+ return findCompatibleNvmBinary() !== null;
1225
1353
  };
1226
1354
  const resolveNodeForOxlint = () => {
1227
1355
  if (isCurrentNodeCompatibleWithOxlint()) return {
@@ -1244,16 +1372,18 @@ const resolveNodeForOxlint = () => {
1244
1372
  const listSourceFilesViaGit = (rootDirectory) => {
1245
1373
  const result = spawnSync("git", [
1246
1374
  "ls-files",
1375
+ "-z",
1247
1376
  "--cached",
1248
1377
  "--others",
1249
- "--exclude-standard"
1378
+ "--exclude-standard",
1379
+ "--recurse-submodules"
1250
1380
  ], {
1251
1381
  cwd: rootDirectory,
1252
1382
  encoding: "utf-8",
1253
1383
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1254
1384
  });
1255
1385
  if (result.error || result.status !== 0) return null;
1256
- return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1386
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1257
1387
  };
1258
1388
  const listSourceFilesViaFilesystem = (rootDirectory) => {
1259
1389
  const filePaths = [];
@@ -1297,10 +1427,13 @@ const collectUnusedFilePaths = (filesIssues) => {
1297
1427
  //#endregion
1298
1428
  //#region src/utils/extract-failed-plugin-name.ts
1299
1429
  const PLUGIN_CONFIG_PATTERN = /(?:^|[/\\\s])([a-z][a-z0-9-]*)\.config\./i;
1430
+ const RC_DOTFILE_PATTERN = /(?:^|[/\\])\.([a-z][a-z0-9-]*?)rc(?:\.[a-z]+)?(?:\b|$)/i;
1300
1431
  const extractFailedPluginName = (error) => {
1301
1432
  for (const errorMessage of getErrorChainMessages(error)) {
1302
1433
  const pluginNameMatch = errorMessage.match(PLUGIN_CONFIG_PATTERN);
1303
1434
  if (pluginNameMatch?.[1]) return pluginNameMatch[1].toLowerCase();
1435
+ const rcMatch = errorMessage.match(RC_DOTFILE_PATTERN);
1436
+ if (rcMatch?.[1]) return rcMatch[1].toLowerCase();
1304
1437
  }
1305
1438
  return null;
1306
1439
  };
@@ -1309,37 +1442,46 @@ const extractFailedPluginName = (error) => {
1309
1442
  const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
1310
1443
  //#endregion
1311
1444
  //#region src/utils/run-knip.ts
1312
- const KNIP_CATEGORY_MAP = {
1313
- files: "Dead Code",
1314
- exports: "Dead Code",
1315
- types: "Dead Code",
1316
- duplicates: "Dead Code"
1317
- };
1318
- const KNIP_MESSAGE_MAP = {
1319
- files: "Unused file",
1320
- exports: "Unused export",
1321
- types: "Unused type",
1322
- duplicates: "Duplicate export"
1323
- };
1324
- const KNIP_SEVERITY_MAP = {
1325
- files: "warning",
1326
- exports: "warning",
1327
- types: "warning",
1328
- duplicates: "warning"
1445
+ const KNIP_ISSUE_TYPE_DESCRIPTORS = {
1446
+ files: {
1447
+ category: "Dead Code",
1448
+ message: "Unused file",
1449
+ severity: "warning"
1450
+ },
1451
+ exports: {
1452
+ category: "Dead Code",
1453
+ message: "Unused export",
1454
+ severity: "warning"
1455
+ },
1456
+ types: {
1457
+ category: "Dead Code",
1458
+ message: "Unused type",
1459
+ severity: "warning"
1460
+ },
1461
+ duplicates: {
1462
+ category: "Dead Code",
1463
+ message: "Duplicate export",
1464
+ severity: "warning"
1465
+ }
1466
+ };
1467
+ const FALLBACK_KNIP_DESCRIPTOR = {
1468
+ category: "Dead Code",
1469
+ message: "Issue",
1470
+ severity: "warning"
1329
1471
  };
1330
1472
  const collectIssueRecords = (records, issueType, rootDirectory) => {
1473
+ const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS[issueType] ?? FALLBACK_KNIP_DESCRIPTOR;
1331
1474
  const diagnostics = [];
1332
1475
  for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
1333
1476
  filePath: path.relative(rootDirectory, issue.filePath),
1334
1477
  plugin: "knip",
1335
1478
  rule: issueType,
1336
- severity: KNIP_SEVERITY_MAP[issueType] ?? "warning",
1337
- message: `${KNIP_MESSAGE_MAP[issueType]}: ${issue.symbol}`,
1479
+ severity: descriptor.severity,
1480
+ message: `${descriptor.message}: ${issue.symbol}`,
1338
1481
  help: "",
1339
1482
  line: 0,
1340
1483
  column: 0,
1341
- category: KNIP_CATEGORY_MAP[issueType] ?? "Dead Code",
1342
- weight: 1
1484
+ category: descriptor.category
1343
1485
  });
1344
1486
  return diagnostics;
1345
1487
  };
@@ -1348,10 +1490,11 @@ const silenced = async (fn) => {
1348
1490
  const originalInfo = console.info;
1349
1491
  const originalWarn = console.warn;
1350
1492
  const originalError = console.error;
1351
- console.log = () => {};
1352
- console.info = () => {};
1353
- console.warn = () => {};
1354
- console.error = () => {};
1493
+ const noop = () => {};
1494
+ console.log = noop;
1495
+ console.info = noop;
1496
+ console.warn = noop;
1497
+ console.error = noop;
1355
1498
  try {
1356
1499
  return await fn();
1357
1500
  } finally {
@@ -1361,8 +1504,8 @@ const silenced = async (fn) => {
1361
1504
  console.error = originalError;
1362
1505
  }
1363
1506
  };
1364
- const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
1365
- const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
1507
+ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
1508
+ const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
1366
1509
  const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
1367
1510
  const failedPlugin = extractFailedPluginName(error);
1368
1511
  if (!failedPlugin || !(failedPlugin in parsedConfig) || disabledPlugins.has(failedPlugin)) return false;
@@ -1381,7 +1524,7 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
1381
1524
  const parsedConfig = options.parsedConfig;
1382
1525
  const disabledPlugins = /* @__PURE__ */ new Set();
1383
1526
  let lastKnipError;
1384
- for (let attempt = 0; attempt <= 5; attempt++) try {
1527
+ for (let attempt = 0; attempt < 6; attempt++) try {
1385
1528
  return await silenced(() => main(options));
1386
1529
  } catch (error) {
1387
1530
  lastKnipError = error;
@@ -1410,17 +1553,17 @@ const runKnip = async (rootDirectory) => {
1410
1553
  if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
1411
1554
  const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
1412
1555
  const diagnostics = [];
1556
+ const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
1413
1557
  for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
1414
1558
  filePath: path.relative(rootDirectory, unusedFilePath),
1415
1559
  plugin: "knip",
1416
1560
  rule: "files",
1417
- severity: KNIP_SEVERITY_MAP["files"],
1418
- message: KNIP_MESSAGE_MAP["files"],
1561
+ severity: filesDescriptor.severity,
1562
+ message: filesDescriptor.message,
1419
1563
  help: "This file is not imported by any other file in the project.",
1420
1564
  line: 0,
1421
1565
  column: 0,
1422
- category: KNIP_CATEGORY_MAP["files"],
1423
- weight: 1
1566
+ category: filesDescriptor.category
1424
1567
  });
1425
1568
  for (const issueType of [
1426
1569
  "exports",
@@ -1430,6 +1573,113 @@ const runKnip = async (rootDirectory) => {
1430
1573
  return diagnostics;
1431
1574
  };
1432
1575
  //#endregion
1576
+ //#region src/utils/batch-include-paths.ts
1577
+ const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1578
+ const batchIncludePaths = (baseArgs, includePaths) => {
1579
+ const baseArgsLength = estimateArgsLength(baseArgs);
1580
+ const batches = [];
1581
+ let currentBatch = [];
1582
+ let currentBatchLength = baseArgsLength;
1583
+ for (const filePath of includePaths) {
1584
+ const entryLength = filePath.length + 1;
1585
+ const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > 24e3;
1586
+ const exceedsFileCount = currentBatch.length >= 500;
1587
+ if (exceedsArgLength || exceedsFileCount) {
1588
+ batches.push(currentBatch);
1589
+ currentBatch = [];
1590
+ currentBatchLength = baseArgsLength;
1591
+ }
1592
+ currentBatch.push(filePath);
1593
+ currentBatchLength += entryLength;
1594
+ }
1595
+ if (currentBatch.length > 0) batches.push(currentBatch);
1596
+ return batches;
1597
+ };
1598
+ //#endregion
1599
+ //#region src/utils/parse-gitattributes-linguist.ts
1600
+ const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
1601
+ const FALSY_VALUES = new Set([
1602
+ "false",
1603
+ "0",
1604
+ "off",
1605
+ "no"
1606
+ ]);
1607
+ const isTruthyLinguistAttribute = (token) => {
1608
+ const match = LINGUIST_ATTRIBUTE_PATTERN.exec(token);
1609
+ if (!match) return false;
1610
+ if (match[1] === void 0) return true;
1611
+ return !FALSY_VALUES.has(match[1].toLowerCase());
1612
+ };
1613
+ const parseGitattributesLinguistPaths = (filePath) => {
1614
+ let content;
1615
+ try {
1616
+ content = fs.readFileSync(filePath, "utf-8");
1617
+ } catch {
1618
+ return [];
1619
+ }
1620
+ const paths = [];
1621
+ for (const rawLine of content.split("\n")) {
1622
+ const line = rawLine.trim();
1623
+ if (line.length === 0 || line.startsWith("#")) continue;
1624
+ const tokens = line.split(/\s+/);
1625
+ if (tokens.length < 2) continue;
1626
+ const [pathSpec, ...attributes] = tokens;
1627
+ if (attributes.some(isTruthyLinguistAttribute)) paths.push(pathSpec);
1628
+ }
1629
+ return paths;
1630
+ };
1631
+ //#endregion
1632
+ //#region src/utils/read-ignore-file.ts
1633
+ const stripGitignoreEscape = (pattern) => {
1634
+ if (pattern.startsWith("\\#") || pattern.startsWith("\\!")) return pattern.slice(1);
1635
+ return pattern;
1636
+ };
1637
+ const readIgnoreFile = (filePath) => {
1638
+ let content;
1639
+ try {
1640
+ content = fs.readFileSync(filePath, "utf-8");
1641
+ } catch (error) {
1642
+ const errnoCode = error?.code;
1643
+ if (errnoCode && errnoCode !== "ENOENT") logger.warn(`Could not read ignore file ${filePath}: ${errnoCode}`);
1644
+ return [];
1645
+ }
1646
+ const patterns = [];
1647
+ for (const line of content.split("\n")) {
1648
+ const trimmed = line.trim();
1649
+ if (trimmed.length === 0) continue;
1650
+ if (trimmed.startsWith("#")) continue;
1651
+ patterns.push(stripGitignoreEscape(trimmed));
1652
+ }
1653
+ return patterns;
1654
+ };
1655
+ //#endregion
1656
+ //#region src/utils/collect-ignore-patterns.ts
1657
+ const IGNORE_FILENAMES = [
1658
+ ".eslintignore",
1659
+ ".oxlintignore",
1660
+ ".prettierignore"
1661
+ ];
1662
+ const cachedPatternsByRoot = /* @__PURE__ */ new Map();
1663
+ const computeIgnorePatterns = (rootDirectory) => {
1664
+ const seen = /* @__PURE__ */ new Set();
1665
+ const patterns = [];
1666
+ const addPattern = (pattern) => {
1667
+ if (seen.has(pattern)) return;
1668
+ seen.add(pattern);
1669
+ patterns.push(pattern);
1670
+ };
1671
+ for (const filename of IGNORE_FILENAMES) for (const pattern of readIgnoreFile(path.join(rootDirectory, filename))) addPattern(pattern);
1672
+ for (const linguistPath of parseGitattributesLinguistPaths(path.join(rootDirectory, ".gitattributes"))) addPattern(linguistPath);
1673
+ return patterns;
1674
+ };
1675
+ const collectIgnorePatterns = (rootDirectory) => {
1676
+ const cached = cachedPatternsByRoot.get(rootDirectory);
1677
+ if (cached !== void 0) return cached;
1678
+ const patterns = computeIgnorePatterns(rootDirectory);
1679
+ cachedPatternsByRoot.set(rootDirectory, patterns);
1680
+ return patterns;
1681
+ };
1682
+ //#endregion
1433
1683
  //#region src/oxlint-config.ts
1434
1684
  const esmRequire$1 = createRequire(import.meta.url);
1435
1685
  const NEXTJS_RULES = {
@@ -1458,7 +1708,23 @@ const REACT_NATIVE_RULES = {
1458
1708
  "react-doctor/rn-no-inline-flatlist-renderitem": "warn",
1459
1709
  "react-doctor/rn-no-legacy-shadow-styles": "warn",
1460
1710
  "react-doctor/rn-prefer-reanimated": "warn",
1461
- "react-doctor/rn-no-single-element-style-array": "warn"
1711
+ "react-doctor/rn-no-single-element-style-array": "warn",
1712
+ "react-doctor/rn-prefer-pressable": "warn",
1713
+ "react-doctor/rn-prefer-expo-image": "warn",
1714
+ "react-doctor/rn-no-non-native-navigator": "warn",
1715
+ "react-doctor/rn-no-scroll-state": "error",
1716
+ "react-doctor/rn-no-scrollview-mapped-list": "warn",
1717
+ "react-doctor/rn-no-inline-object-in-list-item": "warn",
1718
+ "react-doctor/rn-animate-layout-property": "error",
1719
+ "react-doctor/rn-prefer-content-inset-adjustment": "warn",
1720
+ "react-doctor/rn-pressable-shared-value-mutation": "warn",
1721
+ "react-doctor/rn-list-data-mapped": "warn",
1722
+ "react-doctor/rn-list-callback-per-row": "warn",
1723
+ "react-doctor/rn-list-recyclable-without-types": "warn",
1724
+ "react-doctor/rn-animation-reaction-as-derived": "warn",
1725
+ "react-doctor/rn-bottom-sheet-prefer-native": "warn",
1726
+ "react-doctor/rn-scrollview-dynamic-padding": "warn",
1727
+ "react-doctor/rn-style-prefer-boxshadow": "warn"
1462
1728
  };
1463
1729
  const TANSTACK_START_RULES = {
1464
1730
  "react-doctor/tanstack-start-route-property-order": "error",
@@ -1477,22 +1743,41 @@ const TANSTACK_START_RULES = {
1477
1743
  "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
1478
1744
  };
1479
1745
  const REACT_COMPILER_RULES = {
1480
- "react-hooks-js/set-state-in-render": "error",
1481
- "react-hooks-js/immutability": "error",
1482
- "react-hooks-js/refs": "error",
1483
- "react-hooks-js/purity": "error",
1484
- "react-hooks-js/hooks": "error",
1485
- "react-hooks-js/set-state-in-effect": "error",
1486
- "react-hooks-js/globals": "error",
1487
- "react-hooks-js/error-boundaries": "error",
1488
- "react-hooks-js/preserve-manual-memoization": "error",
1489
- "react-hooks-js/unsupported-syntax": "error",
1490
- "react-hooks-js/component-hook-factories": "error",
1491
- "react-hooks-js/static-components": "error",
1492
- "react-hooks-js/use-memo": "error",
1493
- "react-hooks-js/void-use-memo": "error",
1494
- "react-hooks-js/incompatible-library": "error",
1495
- "react-hooks-js/todo": "error"
1746
+ "react-hooks-js/set-state-in-render": "warn",
1747
+ "react-hooks-js/immutability": "warn",
1748
+ "react-hooks-js/refs": "warn",
1749
+ "react-hooks-js/purity": "warn",
1750
+ "react-hooks-js/hooks": "warn",
1751
+ "react-hooks-js/set-state-in-effect": "warn",
1752
+ "react-hooks-js/globals": "warn",
1753
+ "react-hooks-js/error-boundaries": "warn",
1754
+ "react-hooks-js/preserve-manual-memoization": "warn",
1755
+ "react-hooks-js/unsupported-syntax": "warn",
1756
+ "react-hooks-js/component-hook-factories": "warn",
1757
+ "react-hooks-js/static-components": "warn",
1758
+ "react-hooks-js/use-memo": "warn",
1759
+ "react-hooks-js/void-use-memo": "warn",
1760
+ "react-hooks-js/incompatible-library": "warn",
1761
+ "react-hooks-js/todo": "warn"
1762
+ };
1763
+ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
1764
+ if (!hasReactCompiler || customRulesOnly) return [];
1765
+ try {
1766
+ return [{
1767
+ name: "react-hooks-js",
1768
+ specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1769
+ }];
1770
+ } catch {
1771
+ return [];
1772
+ }
1773
+ };
1774
+ const TANSTACK_QUERY_RULES = {
1775
+ "react-doctor/query-stable-query-client": "warn",
1776
+ "react-doctor/query-no-rest-destructuring": "warn",
1777
+ "react-doctor/query-no-void-query-fn": "warn",
1778
+ "react-doctor/query-no-query-in-effect": "warn",
1779
+ "react-doctor/query-mutation-missing-invalidation": "warn",
1780
+ "react-doctor/query-no-usequery-for-mutation": "warn"
1496
1781
  };
1497
1782
  const BUILTIN_REACT_RULES = {
1498
1783
  "react/rules-of-hooks": "error",
@@ -1524,7 +1809,113 @@ const BUILTIN_A11Y_RULES = {
1524
1809
  "jsx-a11y/no-distracting-elements": "error",
1525
1810
  "jsx-a11y/iframe-has-title": "warn"
1526
1811
  };
1527
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRulesOnly = false }) => ({
1812
+ const GLOBAL_REACT_DOCTOR_RULES = {
1813
+ "react-doctor/no-derived-state-effect": "warn",
1814
+ "react-doctor/no-fetch-in-effect": "warn",
1815
+ "react-doctor/no-cascading-set-state": "warn",
1816
+ "react-doctor/no-effect-event-handler": "warn",
1817
+ "react-doctor/no-effect-event-in-deps": "error",
1818
+ "react-doctor/no-prop-callback-in-effect": "warn",
1819
+ "react-doctor/no-derived-useState": "warn",
1820
+ "react-doctor/prefer-useReducer": "warn",
1821
+ "react-doctor/rerender-lazy-state-init": "warn",
1822
+ "react-doctor/rerender-functional-setstate": "warn",
1823
+ "react-doctor/rerender-dependencies": "error",
1824
+ "react-doctor/rerender-state-only-in-handlers": "warn",
1825
+ "react-doctor/rerender-defer-reads-hook": "warn",
1826
+ "react-doctor/advanced-event-handler-refs": "warn",
1827
+ "react-doctor/no-giant-component": "warn",
1828
+ "react-doctor/no-render-in-render": "warn",
1829
+ "react-doctor/no-many-boolean-props": "warn",
1830
+ "react-doctor/no-react19-deprecated-apis": "warn",
1831
+ "react-doctor/no-render-prop-children": "warn",
1832
+ "react-doctor/no-nested-component-definition": "error",
1833
+ "react-doctor/react-compiler-destructure-method": "warn",
1834
+ "react-doctor/no-usememo-simple-expression": "warn",
1835
+ "react-doctor/no-layout-property-animation": "error",
1836
+ "react-doctor/rerender-memo-with-default-value": "warn",
1837
+ "react-doctor/rerender-memo-before-early-return": "warn",
1838
+ "react-doctor/rerender-transitions-scroll": "warn",
1839
+ "react-doctor/rerender-derived-state-from-hook": "warn",
1840
+ "react-doctor/async-defer-await": "warn",
1841
+ "react-doctor/async-await-in-loop": "warn",
1842
+ "react-doctor/rendering-animate-svg-wrapper": "warn",
1843
+ "react-doctor/rendering-hoist-jsx": "warn",
1844
+ "react-doctor/rendering-hydration-mismatch-time": "warn",
1845
+ "react-doctor/no-inline-prop-on-memo-component": "warn",
1846
+ "react-doctor/rendering-hydration-no-flicker": "warn",
1847
+ "react-doctor/rendering-script-defer-async": "warn",
1848
+ "react-doctor/rendering-usetransition-loading": "warn",
1849
+ "react-doctor/no-transition-all": "warn",
1850
+ "react-doctor/no-global-css-variable-animation": "error",
1851
+ "react-doctor/no-large-animated-blur": "warn",
1852
+ "react-doctor/no-scale-from-zero": "warn",
1853
+ "react-doctor/no-permanent-will-change": "warn",
1854
+ "react-doctor/no-eval": "error",
1855
+ "react-doctor/no-secrets-in-client-code": "warn",
1856
+ "react-doctor/no-generic-handler-names": "warn",
1857
+ "react-doctor/js-flatmap-filter": "warn",
1858
+ "react-doctor/js-combine-iterations": "warn",
1859
+ "react-doctor/js-tosorted-immutable": "warn",
1860
+ "react-doctor/js-hoist-regexp": "warn",
1861
+ "react-doctor/js-hoist-intl": "warn",
1862
+ "react-doctor/js-cache-property-access": "warn",
1863
+ "react-doctor/js-length-check-first": "warn",
1864
+ "react-doctor/js-min-max-loop": "warn",
1865
+ "react-doctor/js-set-map-lookups": "warn",
1866
+ "react-doctor/js-batch-dom-css": "warn",
1867
+ "react-doctor/js-index-maps": "warn",
1868
+ "react-doctor/js-cache-storage": "warn",
1869
+ "react-doctor/js-early-exit": "warn",
1870
+ "react-doctor/no-barrel-import": "warn",
1871
+ "react-doctor/no-dynamic-import-path": "warn",
1872
+ "react-doctor/no-full-lodash-import": "warn",
1873
+ "react-doctor/no-moment": "warn",
1874
+ "react-doctor/prefer-dynamic-import": "warn",
1875
+ "react-doctor/use-lazy-motion": "warn",
1876
+ "react-doctor/no-undeferred-third-party": "warn",
1877
+ "react-doctor/no-array-index-as-key": "warn",
1878
+ "react-doctor/no-polymorphic-children": "warn",
1879
+ "react-doctor/rendering-conditional-render": "warn",
1880
+ "react-doctor/rendering-svg-precision": "warn",
1881
+ "react-doctor/no-prevent-default": "warn",
1882
+ "react-doctor/no-document-start-view-transition": "warn",
1883
+ "react-doctor/no-flush-sync": "warn",
1884
+ "react-doctor/server-auth-actions": "error",
1885
+ "react-doctor/server-after-nonblocking": "warn",
1886
+ "react-doctor/server-no-mutable-module-state": "error",
1887
+ "react-doctor/server-cache-with-object-literal": "warn",
1888
+ "react-doctor/server-hoist-static-io": "warn",
1889
+ "react-doctor/server-dedup-props": "warn",
1890
+ "react-doctor/server-sequential-independent-await": "warn",
1891
+ "react-doctor/server-fetch-without-revalidate": "warn",
1892
+ "react-doctor/client-passive-event-listeners": "warn",
1893
+ "react-doctor/client-localstorage-no-version": "warn",
1894
+ "react-doctor/no-inline-bounce-easing": "warn",
1895
+ "react-doctor/no-z-index-9999": "warn",
1896
+ "react-doctor/no-inline-exhaustive-style": "warn",
1897
+ "react-doctor/no-side-tab-border": "warn",
1898
+ "react-doctor/no-pure-black-background": "warn",
1899
+ "react-doctor/no-gradient-text": "warn",
1900
+ "react-doctor/no-dark-mode-glow": "warn",
1901
+ "react-doctor/no-justified-text": "warn",
1902
+ "react-doctor/no-tiny-text": "warn",
1903
+ "react-doctor/no-wide-letter-spacing": "warn",
1904
+ "react-doctor/no-gray-on-colored-background": "warn",
1905
+ "react-doctor/no-layout-transition-inline": "warn",
1906
+ "react-doctor/no-disabled-zoom": "error",
1907
+ "react-doctor/no-outline-none": "warn",
1908
+ "react-doctor/no-long-transition-duration": "warn",
1909
+ "react-doctor/async-parallel": "warn"
1910
+ };
1911
+ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
1912
+ ...Object.keys(GLOBAL_REACT_DOCTOR_RULES),
1913
+ ...Object.keys(NEXTJS_RULES),
1914
+ ...Object.keys(REACT_NATIVE_RULES),
1915
+ ...Object.keys(TANSTACK_START_RULES),
1916
+ ...Object.keys(TANSTACK_QUERY_RULES)
1917
+ ]);
1918
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false }) => ({
1528
1919
  categories: {
1529
1920
  correctness: "off",
1530
1921
  suspicious: "off",
@@ -1534,87 +1925,23 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRul
1534
1925
  style: "off",
1535
1926
  nursery: "off"
1536
1927
  },
1537
- plugins: [
1538
- "react",
1539
- "jsx-a11y",
1540
- ...hasReactCompiler ? [] : ["react-perf"]
1541
- ],
1542
- jsPlugins: [...hasReactCompiler && !customRulesOnly ? [{
1543
- name: "react-hooks-js",
1544
- specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1545
- }] : [], pluginPath],
1928
+ plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
1929
+ jsPlugins: [...resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly), pluginPath],
1546
1930
  rules: {
1547
1931
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1548
1932
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1549
1933
  ...hasReactCompiler && !customRulesOnly ? REACT_COMPILER_RULES : {},
1550
- "react-doctor/no-derived-state-effect": "error",
1551
- "react-doctor/no-fetch-in-effect": "error",
1552
- "react-doctor/no-cascading-set-state": "warn",
1553
- "react-doctor/no-effect-event-handler": "warn",
1554
- "react-doctor/no-derived-useState": "warn",
1555
- "react-doctor/prefer-useReducer": "warn",
1556
- "react-doctor/rerender-lazy-state-init": "warn",
1557
- "react-doctor/rerender-functional-setstate": "warn",
1558
- "react-doctor/rerender-dependencies": "error",
1559
- "react-doctor/no-giant-component": "warn",
1560
- "react-doctor/no-render-in-render": "warn",
1561
- "react-doctor/no-nested-component-definition": "error",
1562
- "react-doctor/no-usememo-simple-expression": "warn",
1563
- "react-doctor/no-layout-property-animation": "error",
1564
- "react-doctor/rerender-memo-with-default-value": "warn",
1565
- "react-doctor/rendering-animate-svg-wrapper": "warn",
1566
- "react-doctor/no-inline-prop-on-memo-component": "warn",
1567
- "react-doctor/rendering-hydration-no-flicker": "warn",
1568
- "react-doctor/rendering-script-defer-async": "warn",
1569
- "react-doctor/no-transition-all": "warn",
1570
- "react-doctor/no-global-css-variable-animation": "error",
1571
- "react-doctor/no-large-animated-blur": "warn",
1572
- "react-doctor/no-scale-from-zero": "warn",
1573
- "react-doctor/no-permanent-will-change": "warn",
1574
- "react-doctor/no-secrets-in-client-code": "error",
1575
- "react-doctor/js-flatmap-filter": "warn",
1576
- "react-doctor/no-barrel-import": "warn",
1577
- "react-doctor/no-full-lodash-import": "warn",
1578
- "react-doctor/no-moment": "warn",
1579
- "react-doctor/prefer-dynamic-import": "warn",
1580
- "react-doctor/use-lazy-motion": "warn",
1581
- "react-doctor/no-undeferred-third-party": "warn",
1582
- "react-doctor/no-array-index-as-key": "warn",
1583
- "react-doctor/rendering-conditional-render": "warn",
1584
- "react-doctor/no-prevent-default": "warn",
1585
- "react-doctor/server-auth-actions": "error",
1586
- "react-doctor/server-after-nonblocking": "warn",
1587
- "react-doctor/client-passive-event-listeners": "warn",
1588
- "react-doctor/query-stable-query-client": "error",
1589
- "react-doctor/query-no-rest-destructuring": "warn",
1590
- "react-doctor/query-no-void-query-fn": "warn",
1591
- "react-doctor/query-no-query-in-effect": "warn",
1592
- "react-doctor/query-mutation-missing-invalidation": "warn",
1593
- "react-doctor/query-no-usequery-for-mutation": "warn",
1594
- "react-doctor/no-inline-bounce-easing": "warn",
1595
- "react-doctor/no-z-index-9999": "warn",
1596
- "react-doctor/no-inline-exhaustive-style": "warn",
1597
- "react-doctor/no-side-tab-border": "warn",
1598
- "react-doctor/no-pure-black-background": "warn",
1599
- "react-doctor/no-gradient-text": "warn",
1600
- "react-doctor/no-dark-mode-glow": "warn",
1601
- "react-doctor/no-justified-text": "warn",
1602
- "react-doctor/no-tiny-text": "warn",
1603
- "react-doctor/no-wide-letter-spacing": "warn",
1604
- "react-doctor/no-gray-on-colored-background": "warn",
1605
- "react-doctor/no-layout-transition-inline": "warn",
1606
- "react-doctor/no-disabled-zoom": "error",
1607
- "react-doctor/no-outline-none": "warn",
1608
- "react-doctor/no-long-transition-duration": "warn",
1609
- "react-doctor/async-parallel": "warn",
1934
+ ...GLOBAL_REACT_DOCTOR_RULES,
1610
1935
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1611
1936
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1612
- ...framework === "tanstack-start" ? TANSTACK_START_RULES : {}
1937
+ ...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
1938
+ ...hasTanStackQuery ? TANSTACK_QUERY_RULES : {}
1613
1939
  }
1614
1940
  });
1615
1941
  //#endregion
1616
1942
  //#region src/utils/neutralize-disable-directives.ts
1617
- const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1943
+ const DISABLE_DIRECTIVE_PATTERN = /(eslint|oxlint)-disable/;
1944
+ const findFilesWithDisableDirectivesViaGit = (rootDirectory, includePaths) => {
1618
1945
  const grepArgs = [
1619
1946
  "grep",
1620
1947
  "-l",
@@ -1628,14 +1955,65 @@ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1628
1955
  encoding: "utf-8",
1629
1956
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1630
1957
  });
1631
- if (result.error || result.status === null) return [];
1632
- if (result.status !== 0 && result.stdout.trim().length === 0) return [];
1958
+ if (result.error || result.status === null) return null;
1959
+ if (result.status === 128) return null;
1633
1960
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1634
1961
  };
1962
+ const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
1963
+ const matches = [];
1964
+ const checkFile = (relativePath) => {
1965
+ if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
1966
+ const absolutePath = path.join(rootDirectory, relativePath);
1967
+ let content;
1968
+ try {
1969
+ content = fs.readFileSync(absolutePath, "utf-8");
1970
+ } catch {
1971
+ return;
1972
+ }
1973
+ if (DISABLE_DIRECTIVE_PATTERN.test(content)) matches.push(relativePath);
1974
+ };
1975
+ if (includePaths && includePaths.length > 0) {
1976
+ for (const candidate of includePaths) checkFile(candidate);
1977
+ return matches;
1978
+ }
1979
+ const stack = [rootDirectory];
1980
+ while (stack.length > 0) {
1981
+ const current = stack.pop();
1982
+ if (current === void 0) continue;
1983
+ let entries;
1984
+ try {
1985
+ entries = fs.readdirSync(current, { withFileTypes: true });
1986
+ } catch {
1987
+ continue;
1988
+ }
1989
+ for (const entry of entries) {
1990
+ if (entry.isDirectory()) {
1991
+ if (entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
1992
+ stack.push(path.join(current, entry.name));
1993
+ continue;
1994
+ }
1995
+ if (!entry.isFile()) continue;
1996
+ const absolute = path.join(current, entry.name);
1997
+ checkFile(path.relative(rootDirectory, absolute));
1998
+ }
1999
+ }
2000
+ return matches;
2001
+ };
2002
+ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => findFilesWithDisableDirectivesViaGit(rootDirectory, includePaths) ?? findFilesWithDisableDirectivesViaFilesystem(rootDirectory, includePaths);
1635
2003
  const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
1636
2004
  const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1637
2005
  const filePaths = findFilesWithDisableDirectives(rootDirectory, includePaths);
1638
2006
  const originalContents = /* @__PURE__ */ new Map();
2007
+ let isRestored = false;
2008
+ const restore = () => {
2009
+ if (isRestored) return;
2010
+ isRestored = true;
2011
+ for (const [absolutePath, originalContent] of originalContents) try {
2012
+ fs.writeFileSync(absolutePath, originalContent);
2013
+ } catch {}
2014
+ };
2015
+ const onExit = () => restore();
2016
+ process.once("exit", onExit);
1639
2017
  for (const relativePath of filePaths) {
1640
2018
  const absolutePath = path.join(rootDirectory, relativePath);
1641
2019
  let originalContent;
@@ -1651,7 +2029,8 @@ const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1651
2029
  }
1652
2030
  }
1653
2031
  return () => {
1654
- for (const [absolutePath, originalContent] of originalContents) fs.writeFileSync(absolutePath, originalContent);
2032
+ restore();
2033
+ process.removeListener("exit", onExit);
1655
2034
  };
1656
2035
  };
1657
2036
  //#endregion
@@ -1661,30 +2040,48 @@ const PLUGIN_CATEGORY_MAP = {
1661
2040
  react: "Correctness",
1662
2041
  "react-hooks": "Correctness",
1663
2042
  "react-hooks-js": "React Compiler",
1664
- "react-perf": "Performance",
1665
- "jsx-a11y": "Accessibility"
2043
+ "react-doctor": "Other",
2044
+ "jsx-a11y": "Accessibility",
2045
+ knip: "Dead Code"
1666
2046
  };
1667
2047
  const RULE_CATEGORY_MAP = {
1668
2048
  "react-doctor/no-derived-state-effect": "State & Effects",
1669
2049
  "react-doctor/no-fetch-in-effect": "State & Effects",
1670
2050
  "react-doctor/no-cascading-set-state": "State & Effects",
1671
2051
  "react-doctor/no-effect-event-handler": "State & Effects",
2052
+ "react-doctor/no-effect-event-in-deps": "State & Effects",
2053
+ "react-doctor/no-prop-callback-in-effect": "State & Effects",
1672
2054
  "react-doctor/no-derived-useState": "State & Effects",
1673
2055
  "react-doctor/prefer-useReducer": "State & Effects",
1674
2056
  "react-doctor/rerender-lazy-state-init": "Performance",
1675
2057
  "react-doctor/rerender-functional-setstate": "Performance",
1676
2058
  "react-doctor/rerender-dependencies": "State & Effects",
2059
+ "react-doctor/rerender-state-only-in-handlers": "Performance",
2060
+ "react-doctor/rerender-defer-reads-hook": "Performance",
2061
+ "react-doctor/advanced-event-handler-refs": "Performance",
1677
2062
  "react-doctor/no-generic-handler-names": "Architecture",
1678
2063
  "react-doctor/no-giant-component": "Architecture",
2064
+ "react-doctor/no-many-boolean-props": "Architecture",
2065
+ "react-doctor/no-react19-deprecated-apis": "Architecture",
2066
+ "react-doctor/no-render-prop-children": "Architecture",
1679
2067
  "react-doctor/no-render-in-render": "Architecture",
1680
2068
  "react-doctor/no-nested-component-definition": "Correctness",
2069
+ "react-doctor/react-compiler-destructure-method": "Architecture",
1681
2070
  "react-doctor/no-usememo-simple-expression": "Performance",
1682
2071
  "react-doctor/no-layout-property-animation": "Performance",
1683
2072
  "react-doctor/rerender-memo-with-default-value": "Performance",
2073
+ "react-doctor/rerender-memo-before-early-return": "Performance",
2074
+ "react-doctor/rerender-transitions-scroll": "Performance",
2075
+ "react-doctor/rerender-derived-state-from-hook": "Performance",
2076
+ "react-doctor/async-defer-await": "Performance",
2077
+ "react-doctor/async-await-in-loop": "Performance",
1684
2078
  "react-doctor/rendering-animate-svg-wrapper": "Performance",
2079
+ "react-doctor/rendering-hoist-jsx": "Performance",
2080
+ "react-doctor/rendering-hydration-mismatch-time": "Correctness",
1685
2081
  "react-doctor/rendering-usetransition-loading": "Performance",
1686
2082
  "react-doctor/rendering-hydration-no-flicker": "Performance",
1687
2083
  "react-doctor/rendering-script-defer-async": "Performance",
2084
+ "react-doctor/no-inline-prop-on-memo-component": "Performance",
1688
2085
  "react-doctor/no-transition-all": "Performance",
1689
2086
  "react-doctor/no-global-css-variable-animation": "Performance",
1690
2087
  "react-doctor/no-large-animated-blur": "Performance",
@@ -1692,14 +2089,19 @@ const RULE_CATEGORY_MAP = {
1692
2089
  "react-doctor/no-permanent-will-change": "Performance",
1693
2090
  "react-doctor/no-secrets-in-client-code": "Security",
1694
2091
  "react-doctor/no-barrel-import": "Bundle Size",
2092
+ "react-doctor/no-dynamic-import-path": "Bundle Size",
1695
2093
  "react-doctor/no-full-lodash-import": "Bundle Size",
1696
2094
  "react-doctor/no-moment": "Bundle Size",
1697
2095
  "react-doctor/prefer-dynamic-import": "Bundle Size",
1698
2096
  "react-doctor/use-lazy-motion": "Bundle Size",
1699
2097
  "react-doctor/no-undeferred-third-party": "Bundle Size",
1700
2098
  "react-doctor/no-array-index-as-key": "Correctness",
2099
+ "react-doctor/no-polymorphic-children": "Architecture",
1701
2100
  "react-doctor/rendering-conditional-render": "Correctness",
2101
+ "react-doctor/rendering-svg-precision": "Performance",
1702
2102
  "react-doctor/no-prevent-default": "Correctness",
2103
+ "react-doctor/no-document-start-view-transition": "Correctness",
2104
+ "react-doctor/no-flush-sync": "Performance",
1703
2105
  "react-doctor/nextjs-no-img-element": "Next.js",
1704
2106
  "react-doctor/nextjs-async-client-component": "Next.js",
1705
2107
  "react-doctor/nextjs-no-a-element": "Next.js",
@@ -1718,7 +2120,14 @@ const RULE_CATEGORY_MAP = {
1718
2120
  "react-doctor/nextjs-no-side-effect-in-get-handler": "Security",
1719
2121
  "react-doctor/server-auth-actions": "Server",
1720
2122
  "react-doctor/server-after-nonblocking": "Server",
2123
+ "react-doctor/server-no-mutable-module-state": "Server",
2124
+ "react-doctor/server-cache-with-object-literal": "Server",
2125
+ "react-doctor/server-hoist-static-io": "Server",
2126
+ "react-doctor/server-dedup-props": "Server",
2127
+ "react-doctor/server-sequential-independent-await": "Server",
2128
+ "react-doctor/server-fetch-without-revalidate": "Server",
1721
2129
  "react-doctor/client-passive-event-listeners": "Performance",
2130
+ "react-doctor/client-localstorage-no-version": "Correctness",
1722
2131
  "react-doctor/query-stable-query-client": "TanStack Query",
1723
2132
  "react-doctor/query-no-rest-destructuring": "TanStack Query",
1724
2133
  "react-doctor/query-no-void-query-fn": "TanStack Query",
@@ -1741,6 +2150,19 @@ const RULE_CATEGORY_MAP = {
1741
2150
  "react-doctor/no-outline-none": "Accessibility",
1742
2151
  "react-doctor/no-long-transition-duration": "Performance",
1743
2152
  "react-doctor/js-flatmap-filter": "Performance",
2153
+ "react-doctor/js-combine-iterations": "Performance",
2154
+ "react-doctor/js-tosorted-immutable": "Performance",
2155
+ "react-doctor/js-hoist-regexp": "Performance",
2156
+ "react-doctor/js-hoist-intl": "Performance",
2157
+ "react-doctor/js-cache-property-access": "Performance",
2158
+ "react-doctor/js-length-check-first": "Performance",
2159
+ "react-doctor/js-min-max-loop": "Performance",
2160
+ "react-doctor/js-set-map-lookups": "Performance",
2161
+ "react-doctor/js-batch-dom-css": "Performance",
2162
+ "react-doctor/js-index-maps": "Performance",
2163
+ "react-doctor/js-cache-storage": "Performance",
2164
+ "react-doctor/js-early-exit": "Performance",
2165
+ "react-doctor/no-eval": "Security",
1744
2166
  "react-doctor/async-parallel": "Performance",
1745
2167
  "react-doctor/rn-no-raw-text": "React Native",
1746
2168
  "react-doctor/rn-no-deprecated-modules": "React Native",
@@ -1750,6 +2172,22 @@ const RULE_CATEGORY_MAP = {
1750
2172
  "react-doctor/rn-no-legacy-shadow-styles": "React Native",
1751
2173
  "react-doctor/rn-prefer-reanimated": "React Native",
1752
2174
  "react-doctor/rn-no-single-element-style-array": "React Native",
2175
+ "react-doctor/rn-prefer-pressable": "React Native",
2176
+ "react-doctor/rn-prefer-expo-image": "React Native",
2177
+ "react-doctor/rn-no-non-native-navigator": "React Native",
2178
+ "react-doctor/rn-no-scroll-state": "React Native",
2179
+ "react-doctor/rn-no-scrollview-mapped-list": "React Native",
2180
+ "react-doctor/rn-no-inline-object-in-list-item": "React Native",
2181
+ "react-doctor/rn-animate-layout-property": "React Native",
2182
+ "react-doctor/rn-prefer-content-inset-adjustment": "React Native",
2183
+ "react-doctor/rn-pressable-shared-value-mutation": "React Native",
2184
+ "react-doctor/rn-list-data-mapped": "React Native",
2185
+ "react-doctor/rn-list-callback-per-row": "React Native",
2186
+ "react-doctor/rn-list-recyclable-without-types": "React Native",
2187
+ "react-doctor/rn-animation-reaction-as-derived": "React Native",
2188
+ "react-doctor/rn-bottom-sheet-prefer-native": "React Native",
2189
+ "react-doctor/rn-scrollview-dynamic-padding": "React Native",
2190
+ "react-doctor/rn-style-prefer-boxshadow": "React Native",
1753
2191
  "react-doctor/tanstack-start-route-property-order": "TanStack Start",
1754
2192
  "react-doctor/tanstack-start-no-direct-fetch-in-loader": "TanStack Start",
1755
2193
  "react-doctor/tanstack-start-server-fn-validate-input": "TanStack Start",
@@ -1775,17 +2213,44 @@ const RULE_HELP_MAP = {
1775
2213
  "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
1776
2214
  "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
1777
2215
  "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
2216
+ "no-effect-event-in-deps": "Call the useEffectEvent callback inside the effect body without listing it; its identity is intentionally unstable",
2217
+ "no-prop-callback-in-effect": "Lift the shared state into a Provider so both sides read the same source — no useEffect-driven sync needed",
1778
2218
  "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
1779
2219
  "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
2220
+ "no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
2221
+ "no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads.",
2222
+ "no-render-prop-children": "Replace `renderXxx` props with compound subcomponents (e.g. `<Modal.Header>`) or `children` so the parent doesn't dictate every customization point",
1780
2223
  "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
1781
2224
  "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
1782
2225
  "no-usememo-simple-expression": "Remove useMemo — property access, math, and ternaries are already cheap without memoization",
1783
2226
  "no-layout-property-animation": "Use `transform: translateX()` or `scale()` instead — they run on the compositor and skip layout/paint",
1784
2227
  "rerender-memo-with-default-value": "Move to module scope: `const EMPTY_ITEMS: Item[] = []` then use as the default value",
1785
2228
  "rendering-animate-svg-wrapper": "Wrap the SVG: `<motion.div animate={...}><svg>...</svg></motion.div>`",
2229
+ "rendering-hoist-jsx": "Move the static JSX to module scope: `const ICON = <svg>...</svg>` outside the component so it isn't recreated each render",
2230
+ "rerender-memo-before-early-return": "Extract the JSX into a memoized child component so the parent's early return short-circuits before the child renders",
2231
+ "rerender-transitions-scroll": "Wrap the setState in startTransition (mark as non-urgent), use useDeferredValue, or stash in a ref + rAF throttle so scroll/pointer events don't trigger a re-render per fire",
2232
+ "rerender-state-only-in-handlers": "Replace useState with useRef when the value is only mutated and never read in render — `ref.current = ...` updates without re-rendering the component",
2233
+ "rerender-defer-reads-hook": "Read the URL state inside the handler (e.g. `new URL(window.location.href).searchParams`) so the component doesn't subscribe and re-render on every URL change",
2234
+ "rerender-derived-state-from-hook": "Use a threshold/media-query hook (e.g. `useMediaQuery(\"(max-width: 767px)\")`) — the component re-renders only when the threshold flips, not every pixel",
2235
+ "advanced-event-handler-refs": "Store the handler in a ref and have the listener read `handlerRef.current()` — the subscription stays put while the latest handler is always called",
2236
+ "async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
2237
+ "async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
2238
+ "react-compiler-destructure-method": "Destructure the method up front: `const { push } = useRouter()` then call `push(...)` directly — clearer dependency graph and easier for React Compiler to memoize",
2239
+ "client-localstorage-no-version": "Bake a version into the storage key (e.g. \"myKey:v1\"); a future schema change can ignore old data instead of crashing on it",
2240
+ "server-sequential-independent-await": "Wrap independent awaits in `Promise.all([...])` so they race instead of waterfalling — second call doesn't depend on the first",
2241
+ "server-fetch-without-revalidate": "Pass `{ next: { revalidate: <seconds> } }` (or `cache: \"no-store\"` / `next: { tags: [...] }`) so stale cached data doesn't silently persist",
2242
+ "rn-list-callback-per-row": "Hoist the handler with useCallback at list scope and pass the row id as a primitive prop, so the row's memo() shallow-compare actually hits",
2243
+ "rn-list-recyclable-without-types": "Add `getItemType={item => item.kind}` so FlashList keeps separate recycle pools per item type — heterogeneous rows shouldn't share recycled cells",
2244
+ "rn-style-prefer-boxshadow": "Use the cross-platform CSS `boxShadow` string (RN v7+): `boxShadow: \"0 2px 8px rgba(0,0,0,0.1)\"` instead of platform-specific shadow*/elevation keys",
2245
+ "rendering-hydration-mismatch-time": "Wrap dynamic time/random values in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional",
2246
+ "no-polymorphic-children": "Expose explicit subcomponents (`<Button.Text>`, `<Button.Icon>`) so consumers don't need to switch on `typeof children`",
2247
+ "rendering-svg-precision": "Truncate path/points/transform decimals to 1–2 digits — sub-pixel precision adds bytes with no visible difference",
2248
+ "no-document-start-view-transition": "Render a <ViewTransition> component and update inside startTransition / useDeferredValue — React calls startViewTransition for you",
2249
+ "no-flush-sync": "Use startTransition for non-urgent updates — flushSync forces a sync flush that skips View Transitions and concurrent rendering",
1786
2250
  "rendering-usetransition-loading": "Replace with `const [isPending, startTransition] = useTransition()` — avoids a re-render for the loading state",
1787
2251
  "rendering-hydration-no-flicker": "Use `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)` or add `suppressHydrationWarning` to the element",
1788
2252
  "rendering-script-defer-async": "Add `defer` for DOM-dependent scripts or `async` for independent ones (analytics). In Next.js, use `<Script strategy=\"afterInteractive\" />` instead",
2253
+ "no-inline-prop-on-memo-component": "Hoist the inline `() => ...` / `[]` / `{}` to a stable reference (useMemo, useCallback, or module scope) so the memoized child doesn't re-render every parent render",
1789
2254
  "no-transition-all": "List specific properties: `transition: \"opacity 200ms, transform 200ms\"` — or in Tailwind use `transition-colors`, `transition-opacity`, or `transition-transform`",
1790
2255
  "no-global-css-variable-animation": "Set the variable on the nearest element instead of a parent, or use `@property` with `inherits: false` to prevent cascade. Better yet, use targeted `element.style.transform` updates",
1791
2256
  "no-large-animated-blur": "Keep blur radius under 10px, or apply blur to a smaller element. Large blurs multiply GPU memory usage with layer size",
@@ -1793,6 +2258,7 @@ const RULE_HELP_MAP = {
1793
2258
  "no-permanent-will-change": "Add will-change on animation start (`onMouseEnter`) and remove on end (`onAnimationEnd`). Permanent promotion wastes GPU memory and can degrade performance",
1794
2259
  "no-secrets-in-client-code": "Move to server-side `process.env.SECRET_NAME`. Only `NEXT_PUBLIC_*` vars are safe for the client (and should not contain secrets)",
1795
2260
  "no-barrel-import": "Import from the direct path: `import { Button } from './components/Button'` instead of `./components`",
2261
+ "no-dynamic-import-path": "Use a string-literal path: `import('./feature/heavy.js')` so the bundler can split this chunk",
1796
2262
  "no-full-lodash-import": "Import the specific function: `import debounce from 'lodash/debounce'` — saves ~70kb",
1797
2263
  "no-moment": "Replace with `import { format } from 'date-fns'` (tree-shakeable) or `import dayjs from 'dayjs'` (2kb)",
1798
2264
  "prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
@@ -1834,7 +2300,11 @@ const RULE_HELP_MAP = {
1834
2300
  "nextjs-no-side-effect-in-get-handler": "Move the side effect to a POST handler and use a <form> or fetch with method POST — GET requests can be triggered by prefetching and are vulnerable to CSRF",
1835
2301
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
1836
2302
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
1837
- "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
2303
+ "server-no-mutable-module-state": "Move per-request data into the action body, headers/cookies, or a request-scope (React.cache, AsyncLocalStorage). Module-scope `let`/`var` is shared across requests.",
2304
+ "server-cache-with-object-literal": "Pass primitives to React.cache()-wrapped functions — argument identity (not deep equality) is the dedup key, so a fresh `{}` per render bypasses the cache",
2305
+ "server-hoist-static-io": "Hoist the read to module scope: `const FONT_DATA = await fetch(new URL('./fonts/Inter.ttf', import.meta.url)).then(r => r.arrayBuffer())` runs once at module load",
2306
+ "server-dedup-props": "Pass the source array once and derive the projection on the client — passing both doubles RSC serialization bytes",
2307
+ "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`. Only do this if the handler does NOT call `event.preventDefault()` — passive listeners silently ignore `preventDefault()`, which breaks features like pull-to-refresh suppression, custom gestures, and nested-scroll containment.",
1838
2308
  "query-stable-query-client": "Move `new QueryClient()` to module scope or wrap in `useState(() => new QueryClient())` — recreating it on every render resets the entire cache",
1839
2309
  "query-no-rest-destructuring": "Destructure only the fields you need: `const { data, isLoading } = useQuery(...)` — rest destructuring subscribes to all fields and causes extra re-renders",
1840
2310
  "query-no-void-query-fn": "queryFn must return a value for the cache. Use the `enabled` option to conditionally disable the query instead of returning undefined",
@@ -1842,6 +2312,19 @@ const RULE_HELP_MAP = {
1842
2312
  "query-mutation-missing-invalidation": "Add `onSuccess: () => queryClient.invalidateQueries({ queryKey: ['...'] })` so cached data stays in sync after the mutation",
1843
2313
  "query-no-usequery-for-mutation": "Use `useMutation()` for POST/PUT/DELETE — it provides onSuccess/onError callbacks, doesn't auto-refetch, and correctly models write operations",
1844
2314
  "js-flatmap-filter": "Use `.flatMap(item => condition ? [value] : [])` — transforms and filters in a single pass instead of creating an intermediate array",
2315
+ "js-hoist-intl": "Hoist `new Intl.NumberFormat(...)` to module scope or wrap in `useMemo` — Intl constructors allocate dozens of objects per locale lookup",
2316
+ "js-cache-property-access": "Hoist the deep member access into a const at the top of the loop body: `const { x, y } = obj.deeply.nested`",
2317
+ "js-length-check-first": "Short-circuit with `a.length === b.length && a.every((x, i) => x === b[i])` — unequal-length arrays exit immediately",
2318
+ "js-combine-iterations": "Combine `.map().filter()` (or similar chains) into a single pass with `.reduce()` or a `for...of` loop to avoid iterating the array twice",
2319
+ "js-tosorted-immutable": "Use `array.toSorted()` (ES2023) instead of `[...array].sort()` for immutable sorting without the spread allocation",
2320
+ "js-hoist-regexp": "Hoist `new RegExp(...)` (or large regex literals) to a module-level constant so it isn't recompiled on every loop iteration",
2321
+ "js-min-max-loop": "Use `Math.min(...array)` / `Math.max(...array)` instead of sorting just to read the first or last element",
2322
+ "js-set-map-lookups": "Use a `Set` or `Map` for repeated membership tests / keyed lookups — `Array.includes`/`find` is O(n) per call",
2323
+ "js-batch-dom-css": "Batch DOM/CSS reads and writes — interleaving them inside a loop causes layout thrashing. Read first, then write",
2324
+ "js-index-maps": "Build an index `Map` once outside the loop instead of `array.find(...)` inside it",
2325
+ "js-cache-storage": "Cache repeated `localStorage`/`sessionStorage` reads in a local variable — each access serializes/deserializes",
2326
+ "js-early-exit": "Add an early `return` / `continue` to flatten deep nesting and short-circuit when the predicate is already known",
2327
+ "no-eval": "Use `JSON.parse` for serialized data, `Function(...)` (still careful) for trusted templates, or refactor to avoid dynamic code execution",
1845
2328
  "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1846
2329
  "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1847
2330
  "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
@@ -1851,6 +2334,19 @@ const RULE_HELP_MAP = {
1851
2334
  "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1852
2335
  "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1853
2336
  "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation",
2337
+ "rn-prefer-pressable": "Use `<Pressable>` from react-native (or react-native-gesture-handler) instead of legacy Touchable* components",
2338
+ "rn-prefer-expo-image": "Use `<Image>` from `expo-image` instead of `react-native` — same prop API, plus disk + memory caching, placeholders, and crossfades",
2339
+ "rn-no-non-native-navigator": "Use `@react-navigation/native-stack` (or `native-tabs` in v7+) for platform-native transitions and gestures",
2340
+ "rn-no-scroll-state": "Track scroll position with a Reanimated shared value (`useAnimatedScrollHandler`) or a ref — `setState` on every scroll event causes re-render storms",
2341
+ "rn-no-scrollview-mapped-list": "Use FlashList, LegendList, or FlatList — `<ScrollView>{items.map(...)}</ScrollView>` mounts every row in memory",
2342
+ "rn-no-inline-object-in-list-item": "Hoist style/object props outside renderItem (StyleSheet.create, useMemo at list scope, or pass primitives) so memo() row components stop bailing",
2343
+ "rn-animate-layout-property": "Animate `transform: [{ translateX/Y }, { scale }]` and `opacity` instead of layout props — layout runs on the JS thread; transform/opacity run on the GPU compositor",
2344
+ "rn-prefer-content-inset-adjustment": "Drop the SafeAreaView wrapper and set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView for native safe-area handling",
2345
+ "rn-pressable-shared-value-mutation": "Wrap in <GestureDetector gesture={Gesture.Tap()...}> so the press animation runs on the UI thread instead of bouncing across the JS bridge",
2346
+ "rn-list-data-mapped": "Wrap the projection in `useMemo(() => items.map(...), [items])` so the list's `data` prop has a stable reference across parent renders",
2347
+ "rn-animation-reaction-as-derived": "Replace useAnimatedReaction with `useDerivedValue(() => ..., [deps])` — shorter, native dependency tracking, no side-effect implication",
2348
+ "rn-bottom-sheet-prefer-native": "Use `<Modal presentationStyle=\"formSheet\">` (RN v7+) for native gesture handling and snap points",
2349
+ "rn-scrollview-dynamic-padding": "Use `contentInset={{ bottom: dynamicValue }}` — the OS applies it as an offset without reflowing the scroll content",
1854
2350
  "tanstack-start-route-property-order": "Follow the order: params/validateSearch → loaderDeps → context → beforeLoad → loader → head. See https://tanstack.com/router/latest/docs/eslint/create-route-property-order",
1855
2351
  "tanstack-start-no-direct-fetch-in-loader": "Use `createServerFn()` from @tanstack/react-start — provides type-safe RPC, input validation, and proper server/client code splitting",
1856
2352
  "tanstack-start-server-fn-validate-input": "Add `.inputValidator(schema)` before `.handler()` — data crosses a network boundary and must be validated at runtime",
@@ -1905,35 +2401,61 @@ const resolvePluginPath = () => {
1905
2401
  const resolveDiagnosticCategory = (plugin, rule) => {
1906
2402
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
1907
2403
  };
1908
- const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1909
- const batchIncludePaths = (baseArgs, includePaths) => {
1910
- const baseArgsLength = estimateArgsLength(baseArgs);
1911
- const batches = [];
1912
- let currentBatch = [];
1913
- let currentBatchLength = baseArgsLength;
1914
- for (const filePath of includePaths) {
1915
- const entryLength = filePath.length + 1;
1916
- const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > 24e3;
1917
- const exceedsFileCount = currentBatch.length >= 500;
1918
- if (exceedsArgLength || exceedsFileCount) {
1919
- batches.push(currentBatch);
1920
- currentBatch = [];
1921
- currentBatchLength = baseArgsLength;
1922
- }
1923
- currentBatch.push(filePath);
1924
- currentBatchLength += entryLength;
2404
+ const SANITIZED_ENV = (() => {
2405
+ const sanitized = {};
2406
+ for (const [name, value] of Object.entries(process.env)) {
2407
+ if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
2408
+ if (name.startsWith("npm_config_")) continue;
2409
+ sanitized[name] = value;
1925
2410
  }
1926
- if (currentBatch.length > 0) batches.push(currentBatch);
1927
- return batches;
1928
- };
2411
+ return sanitized;
2412
+ })();
2413
+ const OXLINT_SPAWN_TIMEOUT_MS = 5 * 6e4;
1929
2414
  const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
1930
- const child = spawn(nodeBinaryPath, args, { cwd: rootDirectory });
2415
+ const child = spawn(nodeBinaryPath, args, {
2416
+ cwd: rootDirectory,
2417
+ env: SANITIZED_ENV
2418
+ });
2419
+ const timeoutHandle = setTimeout(() => {
2420
+ child.kill("SIGKILL");
2421
+ reject(/* @__PURE__ */ new Error(`oxlint did not return within ${OXLINT_SPAWN_TIMEOUT_MS / 1e3}s — please report`));
2422
+ }, OXLINT_SPAWN_TIMEOUT_MS);
2423
+ timeoutHandle.unref?.();
1931
2424
  const stdoutBuffers = [];
1932
2425
  const stderrBuffers = [];
1933
- child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1934
- child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1935
- child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1936
- child.on("close", (code, signal) => {
2426
+ let stdoutByteCount = 0;
2427
+ let stderrByteCount = 0;
2428
+ let didKillForSize = false;
2429
+ const killIfTooLarge = (incomingBytes, isStdout) => {
2430
+ if (isStdout) stdoutByteCount += incomingBytes;
2431
+ else stderrByteCount += incomingBytes;
2432
+ if (stdoutByteCount + stderrByteCount > 52428800 && !didKillForSize) {
2433
+ didKillForSize = true;
2434
+ child.kill("SIGKILL");
2435
+ return true;
2436
+ }
2437
+ return false;
2438
+ };
2439
+ child.stdout.on("data", (buffer) => {
2440
+ if (didKillForSize) return;
2441
+ stdoutBuffers.push(buffer);
2442
+ killIfTooLarge(buffer.length, true);
2443
+ });
2444
+ child.stderr.on("data", (buffer) => {
2445
+ if (didKillForSize) return;
2446
+ stderrBuffers.push(buffer);
2447
+ killIfTooLarge(buffer.length, false);
2448
+ });
2449
+ child.on("error", (error) => {
2450
+ clearTimeout(timeoutHandle);
2451
+ reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`));
2452
+ });
2453
+ child.on("close", (_code, signal) => {
2454
+ clearTimeout(timeoutHandle);
2455
+ if (didKillForSize) {
2456
+ reject(/* @__PURE__ */ new Error(`oxlint output exceeded ${PROXY_OUTPUT_MAX_BYTES} bytes — scan a smaller subset with --diff or --staged`));
2457
+ return;
2458
+ }
1937
2459
  if (signal) {
1938
2460
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1939
2461
  const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
@@ -1952,15 +2474,23 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
1952
2474
  resolve(output);
1953
2475
  });
1954
2476
  });
2477
+ const isOxlintOutput = (value) => {
2478
+ if (typeof value !== "object" || value === null) return false;
2479
+ const candidate = value;
2480
+ return Array.isArray(candidate.diagnostics);
2481
+ };
1955
2482
  const parseOxlintOutput = (stdout) => {
1956
2483
  if (!stdout) return [];
1957
- let output;
2484
+ const jsonStart = stdout.indexOf("{");
2485
+ const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
2486
+ let parsed;
1958
2487
  try {
1959
- output = JSON.parse(stdout);
2488
+ parsed = JSON.parse(sanitizedStdout);
1960
2489
  } catch {
1961
2490
  throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
1962
2491
  }
1963
- return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2492
+ if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
2493
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
1964
2494
  const { plugin, rule } = parseRuleCode(diagnostic.code);
1965
2495
  const primaryLabel = diagnostic.labels[0];
1966
2496
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -1977,18 +2507,48 @@ const parseOxlintOutput = (stdout) => {
1977
2507
  };
1978
2508
  });
1979
2509
  };
1980
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false) => {
2510
+ const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
2511
+ const resolveTsConfigRelativePath = (rootDirectory) => {
2512
+ for (const filename of TSCONFIG_FILENAMES) if (fs.existsSync(path.join(rootDirectory, filename))) return `./${filename}`;
2513
+ return null;
2514
+ };
2515
+ let didValidateRuleRegistration = false;
2516
+ const validateRuleRegistration = () => {
2517
+ if (didValidateRuleRegistration) return;
2518
+ didValidateRuleRegistration = true;
2519
+ const missingHelp = [];
2520
+ const missingCategory = [];
2521
+ for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2522
+ const ruleName = fullKey.replace(/^react-doctor\//, "");
2523
+ if (!(fullKey in RULE_CATEGORY_MAP)) missingCategory.push(fullKey);
2524
+ if (!(ruleName in RULE_HELP_MAP)) missingHelp.push(fullKey);
2525
+ }
2526
+ if (missingCategory.length > 0 || missingHelp.length > 0) {
2527
+ const detail = [missingCategory.length > 0 ? `Missing RULE_CATEGORY_MAP entries: ${missingCategory.join(", ")}` : null, missingHelp.length > 0 ? `Missing RULE_HELP_MAP entries: ${missingHelp.join(", ")}` : null].filter((entry) => entry !== null).join("; ");
2528
+ console.warn(`[react-doctor] rule-registration drift: ${detail}`);
2529
+ }
2530
+ };
2531
+ const runOxlint = async (options) => {
2532
+ const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
2533
+ validateRuleRegistration();
1981
2534
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1982
- const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
2535
+ const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
2536
+ const configPath = path.join(configDirectory, "oxlintrc.json");
1983
2537
  const config = createOxlintConfig({
1984
2538
  pluginPath: resolvePluginPath(),
1985
2539
  framework,
1986
2540
  hasReactCompiler,
2541
+ hasTanStackQuery,
1987
2542
  customRulesOnly
1988
2543
  });
1989
- const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths);
2544
+ const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
1990
2545
  try {
1991
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
2546
+ const fileHandle = fs.openSync(configPath, "wx", 384);
2547
+ try {
2548
+ fs.writeFileSync(fileHandle, JSON.stringify(config));
2549
+ } finally {
2550
+ fs.closeSync(fileHandle);
2551
+ }
1992
2552
  const baseArgs = [
1993
2553
  resolveOxlintBinary(),
1994
2554
  "-c",
@@ -1996,7 +2556,16 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1996
2556
  "--format",
1997
2557
  "json"
1998
2558
  ];
1999
- if (hasTypeScript) baseArgs.push("--tsconfig", "./tsconfig.json");
2559
+ if (hasTypeScript) {
2560
+ const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
2561
+ if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
2562
+ }
2563
+ const combinedPatterns = collectIgnorePatterns(rootDirectory);
2564
+ if (combinedPatterns.length > 0) {
2565
+ const combinedIgnorePath = path.join(configDirectory, "combined.ignore");
2566
+ fs.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
2567
+ baseArgs.push("--ignore-path", combinedIgnorePath);
2568
+ }
2000
2569
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
2001
2570
  const allDiagnostics = [];
2002
2571
  for (const batch of fileBatches) {
@@ -2006,7 +2575,10 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
2006
2575
  return allDiagnostics;
2007
2576
  } finally {
2008
2577
  restoreDisableDirectives();
2009
- if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
2578
+ fs.rmSync(configDirectory, {
2579
+ recursive: true,
2580
+ force: true
2581
+ });
2010
2582
  }
2011
2583
  };
2012
2584
  //#endregion
@@ -2069,10 +2641,10 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
2069
2641
  };
2070
2642
  const writeDiagnosticsDirectory = (diagnostics) => {
2071
2643
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
2072
- mkdirSync(outputDirectory);
2644
+ mkdirSync(outputDirectory, { recursive: true });
2073
2645
  const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
2074
2646
  for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
2075
- writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
2647
+ writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
2076
2648
  return outputDirectory;
2077
2649
  };
2078
2650
  const buildScoreBarSegments = (score) => {
@@ -2191,17 +2763,17 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
2191
2763
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
2192
2764
  }
2193
2765
  };
2194
- const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
2766
+ const resolveOxlintNode = async (isLintEnabled, isQuiet) => {
2195
2767
  if (!isLintEnabled) return null;
2196
2768
  const nodeResolution = resolveNodeForOxlint();
2197
2769
  if (nodeResolution) {
2198
- if (!nodeResolution.isCurrentNode && !isScoreOnly) {
2770
+ if (!nodeResolution.isCurrentNode && !isQuiet) {
2199
2771
  logger.warn(`Node ${process.version} is unsupported by oxlint. Using Node ${nodeResolution.version} from nvm.`);
2200
2772
  logger.break();
2201
2773
  }
2202
2774
  return nodeResolution.binaryPath;
2203
2775
  }
2204
- if (isScoreOnly) return null;
2776
+ if (isQuiet) return null;
2205
2777
  logger.warn(`Node ${process.version} is not compatible with oxlint (requires ${OXLINT_NODE_REQUIREMENT}). Lint checks will be skipped.`);
2206
2778
  if (isNvmInstalled() && process.stdin.isTTY) {
2207
2779
  const { shouldInstallNode } = await prompts({
@@ -2235,9 +2807,11 @@ const mergeScanOptions = (inputOptions, userConfig) => ({
2235
2807
  verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
2236
2808
  scoreOnly: inputOptions.scoreOnly ?? false,
2237
2809
  offline: inputOptions.offline ?? false,
2810
+ silent: inputOptions.silent ?? false,
2238
2811
  includePaths: inputOptions.includePaths ?? [],
2239
2812
  customRulesOnly: userConfig?.customRulesOnly ?? false,
2240
- share: userConfig?.share ?? true
2813
+ share: userConfig?.share ?? true,
2814
+ respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true
2241
2815
  });
2242
2816
  const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount) => {
2243
2817
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
@@ -2256,23 +2830,49 @@ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths
2256
2830
  };
2257
2831
  const scan = async (directory, inputOptions = {}) => {
2258
2832
  const startTime = performance.now();
2259
- const projectInfo = discoverProject(directory);
2260
2833
  const userConfig = inputOptions.configOverride !== void 0 ? inputOptions.configOverride : loadConfig(directory);
2261
2834
  const options = mergeScanOptions(inputOptions, userConfig);
2835
+ const wasLoggerSilent = isLoggerSilent();
2836
+ const wasSpinnerSilent = isSpinnerSilent();
2837
+ if (options.silent) {
2838
+ setLoggerSilent(true);
2839
+ setSpinnerSilent(true);
2840
+ }
2841
+ try {
2842
+ return await runScan(directory, options, userConfig, startTime);
2843
+ } finally {
2844
+ if (options.silent) {
2845
+ setLoggerSilent(wasLoggerSilent);
2846
+ setSpinnerSilent(wasSpinnerSilent);
2847
+ }
2848
+ }
2849
+ };
2850
+ const runScan = async (directory, options, userConfig, startTime) => {
2851
+ const projectInfo = discoverProject(directory);
2262
2852
  const { includePaths } = options;
2263
2853
  const isDiffMode = includePaths.length > 0;
2264
- if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
2854
+ if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(directory));
2265
2855
  const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(directory, userConfig);
2266
2856
  const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
2267
2857
  if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount);
2268
2858
  let didLintFail = false;
2269
2859
  let didDeadCodeFail = false;
2270
- const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly);
2860
+ const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
2271
2861
  if (options.lint && !resolvedNodeBinaryPath) didLintFail = true;
2272
2862
  const lintPromise = resolvedNodeBinaryPath ? (async () => {
2273
2863
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
2274
2864
  try {
2275
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, resolvedNodeBinaryPath, options.customRulesOnly);
2865
+ const lintDiagnostics = await runOxlint({
2866
+ rootDirectory: directory,
2867
+ hasTypeScript: projectInfo.hasTypeScript,
2868
+ framework: projectInfo.framework,
2869
+ hasReactCompiler: projectInfo.hasReactCompiler,
2870
+ hasTanStackQuery: projectInfo.hasTanStackQuery,
2871
+ includePaths: lintIncludePaths,
2872
+ nodeBinaryPath: resolvedNodeBinaryPath,
2873
+ customRulesOnly: options.customRulesOnly,
2874
+ respectInlineDisables: options.respectInlineDisables
2875
+ });
2276
2876
  lintSpinner?.succeed("Running lint checks.");
2277
2877
  return lintDiagnostics;
2278
2878
  } catch (error) {
@@ -2306,7 +2906,13 @@ const scan = async (directory, inputOptions = {}) => {
2306
2906
  }
2307
2907
  })() : Promise.resolve([]);
2308
2908
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
2309
- const diagnostics = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig);
2909
+ const diagnostics = combineDiagnostics({
2910
+ lintDiagnostics,
2911
+ deadCodeDiagnostics,
2912
+ directory,
2913
+ isDiffMode,
2914
+ userConfig
2915
+ });
2310
2916
  const elapsedMilliseconds = performance.now() - startTime;
2311
2917
  const skippedChecks = [];
2312
2918
  if (didLintFail) skippedChecks.push("lint");
@@ -2314,14 +2920,17 @@ const scan = async (directory, inputOptions = {}) => {
2314
2920
  const hasSkippedChecks = skippedChecks.length > 0;
2315
2921
  const scoreResult = options.offline ? calculateScoreLocally(diagnostics) : await calculateScore(diagnostics);
2316
2922
  const noScoreMessage = OFFLINE_MESSAGE;
2923
+ const buildResult = () => ({
2924
+ diagnostics,
2925
+ score: scoreResult,
2926
+ skippedChecks,
2927
+ project: projectInfo,
2928
+ elapsedMilliseconds
2929
+ });
2317
2930
  if (options.scoreOnly) {
2318
2931
  if (scoreResult) logger.log(`${scoreResult.score}`);
2319
2932
  else logger.dim(noScoreMessage);
2320
- return {
2321
- diagnostics,
2322
- scoreResult,
2323
- skippedChecks
2324
- };
2933
+ return buildResult();
2325
2934
  }
2326
2935
  if (diagnostics.length === 0) {
2327
2936
  if (hasSkippedChecks) {
@@ -2336,11 +2945,7 @@ const scan = async (directory, inputOptions = {}) => {
2336
2945
  printBranding(scoreResult.score);
2337
2946
  printScoreGauge(scoreResult.score, scoreResult.label);
2338
2947
  } else logger.dim(` ${noScoreMessage}`);
2339
- return {
2340
- diagnostics,
2341
- scoreResult,
2342
- skippedChecks
2343
- };
2948
+ return buildResult();
2344
2949
  }
2345
2950
  printDiagnostics(diagnostics, options.verbose);
2346
2951
  const displayedSourceFileCount = isDiffMode ? includePaths.length : lintSourceFileCount;
@@ -2351,74 +2956,225 @@ const scan = async (directory, inputOptions = {}) => {
2351
2956
  logger.break();
2352
2957
  logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`);
2353
2958
  }
2959
+ return buildResult();
2960
+ };
2961
+ //#endregion
2962
+ //#region src/utils/summarize-diagnostics.ts
2963
+ const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
2964
+ let errorCount = 0;
2965
+ let warningCount = 0;
2966
+ const affectedFiles = /* @__PURE__ */ new Set();
2967
+ for (const diagnostic of diagnostics) {
2968
+ if (diagnostic.severity === "error") errorCount++;
2969
+ else warningCount++;
2970
+ affectedFiles.add(diagnostic.filePath);
2971
+ }
2354
2972
  return {
2355
- diagnostics,
2356
- scoreResult,
2357
- skippedChecks
2973
+ errorCount,
2974
+ warningCount,
2975
+ affectedFileCount: affectedFiles.size,
2976
+ totalDiagnosticCount: diagnostics.length,
2977
+ score: worstScore,
2978
+ scoreLabel: worstScoreLabel
2358
2979
  };
2359
2980
  };
2360
2981
  //#endregion
2361
- //#region src/utils/get-diff-files.ts
2362
- const getCurrentBranch = (directory) => {
2363
- try {
2364
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
2365
- cwd: directory,
2366
- stdio: "pipe"
2367
- }).toString().trim();
2368
- return branch === "HEAD" ? null : branch;
2369
- } catch {
2370
- return null;
2982
+ //#region src/utils/build-json-report.ts
2983
+ const toJsonDiff = (diff) => {
2984
+ if (!diff) return null;
2985
+ return {
2986
+ baseBranch: diff.baseBranch,
2987
+ currentBranch: diff.currentBranch,
2988
+ changedFileCount: diff.changedFiles.length,
2989
+ isCurrentChanges: Boolean(diff.isCurrentChanges)
2990
+ };
2991
+ };
2992
+ const findWorstScoredProject = (projects) => {
2993
+ let worst = null;
2994
+ let worstScore = Number.POSITIVE_INFINITY;
2995
+ for (const project of projects) {
2996
+ const score = project.score?.score;
2997
+ if (typeof score !== "number") continue;
2998
+ if (score < worstScore) {
2999
+ worstScore = score;
3000
+ worst = project;
3001
+ }
2371
3002
  }
3003
+ return worst;
2372
3004
  };
2373
- const detectDefaultBranch = (directory) => {
3005
+ const buildJsonReport = (input) => {
3006
+ const projects = input.scans.map(({ directory, result }) => ({
3007
+ directory,
3008
+ project: result.project,
3009
+ diagnostics: result.diagnostics,
3010
+ score: result.score,
3011
+ skippedChecks: result.skippedChecks,
3012
+ elapsedMilliseconds: result.elapsedMilliseconds
3013
+ }));
3014
+ const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
3015
+ const worstScoredProject = findWorstScoredProject(projects);
3016
+ const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
3017
+ return {
3018
+ schemaVersion: 1,
3019
+ version: input.version,
3020
+ ok: true,
3021
+ directory: input.directory,
3022
+ mode: input.mode,
3023
+ diff: toJsonDiff(input.diff),
3024
+ projects,
3025
+ diagnostics: flattenedDiagnostics,
3026
+ summary,
3027
+ elapsedMilliseconds: input.totalElapsedMilliseconds,
3028
+ error: null
3029
+ };
3030
+ };
3031
+ //#endregion
3032
+ //#region src/utils/build-json-report-error.ts
3033
+ const safeStringify = (value) => {
2374
3034
  try {
2375
- return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
2376
- cwd: directory,
2377
- stdio: "pipe"
2378
- }).toString().trim().replace("refs/remotes/origin/", "");
3035
+ return String(value);
2379
3036
  } catch {
2380
- for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
2381
- execSync(`git rev-parse --verify ${candidate}`, {
2382
- cwd: directory,
2383
- stdio: "pipe"
2384
- });
2385
- return candidate;
2386
- } catch {}
2387
- return null;
3037
+ return "Unrepresentable error";
2388
3038
  }
2389
3039
  };
2390
- const getChangedFilesSinceBranch = (directory, baseBranch) => {
3040
+ const safeGetErrorChain = (error) => {
2391
3041
  try {
2392
- const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
2393
- cwd: directory,
2394
- stdio: "pipe"
2395
- }).toString().trim()}`, {
2396
- cwd: directory,
2397
- stdio: "pipe"
2398
- }).toString().trim();
2399
- if (!output) return [];
2400
- return output.split("\n").filter(Boolean);
3042
+ return getErrorChainMessages(error);
2401
3043
  } catch {
2402
- return [];
3044
+ return [safeStringify(error)];
3045
+ }
3046
+ };
3047
+ const buildJsonReportError = (input) => {
3048
+ const chain = safeGetErrorChain(input.error);
3049
+ const errorPayload = input.error instanceof Error ? {
3050
+ message: input.error.message || input.error.name || "Error",
3051
+ name: input.error.name || "Error",
3052
+ chain
3053
+ } : {
3054
+ message: safeStringify(input.error),
3055
+ name: "Error",
3056
+ chain
3057
+ };
3058
+ return {
3059
+ schemaVersion: 1,
3060
+ version: input.version,
3061
+ ok: false,
3062
+ directory: input.directory,
3063
+ mode: input.mode ?? "full",
3064
+ diff: null,
3065
+ projects: [],
3066
+ diagnostics: [],
3067
+ summary: {
3068
+ errorCount: 0,
3069
+ warningCount: 0,
3070
+ affectedFileCount: 0,
3071
+ totalDiagnosticCount: 0,
3072
+ score: null,
3073
+ scoreLabel: null
3074
+ },
3075
+ elapsedMilliseconds: input.elapsedMilliseconds,
3076
+ error: errorPayload
3077
+ };
3078
+ };
3079
+ //#endregion
3080
+ //#region src/utils/get-diff-files.ts
3081
+ const runGit = (cwd, args) => {
3082
+ const result = spawnSync("git", args, {
3083
+ cwd,
3084
+ stdio: [
3085
+ "ignore",
3086
+ "pipe",
3087
+ "pipe"
3088
+ ],
3089
+ encoding: "utf-8"
3090
+ });
3091
+ if (result.error || result.status !== 0) return null;
3092
+ return result.stdout.toString().trim();
3093
+ };
3094
+ const getCurrentBranch = (directory) => {
3095
+ const branch = runGit(directory, [
3096
+ "rev-parse",
3097
+ "--abbrev-ref",
3098
+ "HEAD"
3099
+ ]);
3100
+ if (!branch) return null;
3101
+ return branch === "HEAD" ? null : branch;
3102
+ };
3103
+ const detectDefaultBranch = (directory) => {
3104
+ const reference = runGit(directory, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
3105
+ if (reference) return reference.replace("refs/remotes/origin/", "");
3106
+ const output = runGit(directory, [
3107
+ "for-each-ref",
3108
+ "--format=%(refname:short)",
3109
+ ...DEFAULT_BRANCH_CANDIDATES.map((candidate) => `refs/heads/${candidate}`)
3110
+ ]);
3111
+ if (output) {
3112
+ const firstLine = output.split("\n")[0]?.trim();
3113
+ if (firstLine) return firstLine;
2403
3114
  }
3115
+ return null;
3116
+ };
3117
+ const branchExists = (directory, branch) => {
3118
+ const result = spawnSync("git", [
3119
+ "rev-parse",
3120
+ "--verify",
3121
+ branch
3122
+ ], {
3123
+ cwd: directory,
3124
+ stdio: [
3125
+ "ignore",
3126
+ "pipe",
3127
+ "pipe"
3128
+ ]
3129
+ });
3130
+ return !result.error && result.status === 0;
3131
+ };
3132
+ const runGitNullSeparated = (cwd, args) => {
3133
+ const result = spawnSync("git", args, {
3134
+ cwd,
3135
+ stdio: [
3136
+ "ignore",
3137
+ "pipe",
3138
+ "pipe"
3139
+ ],
3140
+ encoding: "utf-8"
3141
+ });
3142
+ if (result.error || result.status !== 0) return null;
3143
+ return result.stdout.toString().split("\0").filter((filePath) => filePath.length > 0);
3144
+ };
3145
+ const getChangedFilesSinceBranch = (directory, baseBranch) => {
3146
+ const mergeBase = runGit(directory, [
3147
+ "merge-base",
3148
+ baseBranch,
3149
+ "HEAD"
3150
+ ]);
3151
+ if (mergeBase === null) return null;
3152
+ return runGitNullSeparated(directory, [
3153
+ "diff",
3154
+ "-z",
3155
+ "--name-only",
3156
+ "--diff-filter=ACMR",
3157
+ "--relative",
3158
+ mergeBase
3159
+ ]);
2404
3160
  };
2405
3161
  const getUncommittedChangedFiles = (directory) => {
2406
- try {
2407
- const output = execSync("git diff --name-only --diff-filter=ACMR --relative HEAD", {
2408
- cwd: directory,
2409
- stdio: "pipe"
2410
- }).toString().trim();
2411
- if (!output) return [];
2412
- return output.split("\n").filter(Boolean);
2413
- } catch {
2414
- return [];
2415
- }
3162
+ return runGitNullSeparated(directory, [
3163
+ "diff",
3164
+ "-z",
3165
+ "--name-only",
3166
+ "--diff-filter=ACMR",
3167
+ "--relative",
3168
+ "HEAD"
3169
+ ]) ?? [];
2416
3170
  };
2417
3171
  const getDiffInfo = (directory, explicitBaseBranch) => {
3172
+ if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) throw new Error("Diff base branch cannot be empty.");
2418
3173
  const currentBranch = getCurrentBranch(directory);
2419
3174
  if (!currentBranch) return null;
2420
3175
  const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
2421
3176
  if (!baseBranch) return null;
3177
+ if (explicitBaseBranch && !branchExists(directory, explicitBaseBranch)) throw new Error(`Diff base branch "${explicitBaseBranch}" does not exist (run \`git fetch\` to update remote refs).`);
2422
3178
  if (currentBranch === baseBranch) {
2423
3179
  const uncommittedFiles = getUncommittedChangedFiles(directory);
2424
3180
  if (uncommittedFiles.length === 0) return null;
@@ -2429,10 +3185,12 @@ const getDiffInfo = (directory, explicitBaseBranch) => {
2429
3185
  isCurrentChanges: true
2430
3186
  };
2431
3187
  }
3188
+ const changedFiles = getChangedFilesSinceBranch(directory, baseBranch);
3189
+ if (changedFiles === null) return null;
2432
3190
  return {
2433
3191
  currentBranch,
2434
3192
  baseBranch,
2435
- changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
3193
+ changedFiles
2436
3194
  };
2437
3195
  };
2438
3196
  const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
@@ -2442,6 +3200,7 @@ const getStagedFilePaths = (directory) => {
2442
3200
  const result = spawnSync("git", [
2443
3201
  "diff",
2444
3202
  "--cached",
3203
+ "-z",
2445
3204
  "--name-only",
2446
3205
  "--diff-filter=ACMR",
2447
3206
  "--relative"
@@ -2451,9 +3210,9 @@ const getStagedFilePaths = (directory) => {
2451
3210
  maxBuffer: GIT_SHOW_MAX_BUFFER_BYTES
2452
3211
  });
2453
3212
  if (result.error || result.status !== 0) return [];
2454
- const output = result.stdout.toString().trim();
3213
+ const output = result.stdout.toString();
2455
3214
  if (!output) return [];
2456
- return output.split("\n").filter(Boolean);
3215
+ return output.split("\0").filter((filePath) => filePath.length > 0);
2457
3216
  };
2458
3217
  const readStagedContent = (directory, relativePath) => {
2459
3218
  const result = spawnSync("git", ["show", `:${relativePath}`], {
@@ -2465,6 +3224,18 @@ const readStagedContent = (directory, relativePath) => {
2465
3224
  return result.stdout.toString();
2466
3225
  };
2467
3226
  const getStagedSourceFiles = (directory) => getStagedFilePaths(directory).filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
3227
+ const PROJECT_CONFIG_FILENAMES = [
3228
+ "tsconfig.json",
3229
+ "tsconfig.base.json",
3230
+ "package.json",
3231
+ "react-doctor.config.json",
3232
+ "knip.json",
3233
+ "knip.jsonc",
3234
+ ".knip.json",
3235
+ ".knip.jsonc",
3236
+ "oxlint.json",
3237
+ ".oxlintrc.json"
3238
+ ];
2468
3239
  const materializeStagedFiles = (directory, stagedFiles, tempDirectory) => {
2469
3240
  const materializedFiles = [];
2470
3241
  for (const relativePath of stagedFiles) {
@@ -2475,11 +3246,7 @@ const materializeStagedFiles = (directory, stagedFiles, tempDirectory) => {
2475
3246
  fs.writeFileSync(targetPath, content);
2476
3247
  materializedFiles.push(relativePath);
2477
3248
  }
2478
- for (const configFilename of [
2479
- "tsconfig.json",
2480
- "package.json",
2481
- "react-doctor.config.json"
2482
- ]) {
3249
+ for (const configFilename of PROJECT_CONFIG_FILENAMES) {
2483
3250
  const sourcePath = path.join(directory, configFilename);
2484
3251
  const targetPath = path.join(tempDirectory, configFilename);
2485
3252
  if (fs.existsSync(sourcePath) && !fs.existsSync(targetPath)) fs.cpSync(sourcePath, targetPath);
@@ -2503,14 +3270,18 @@ const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
2503
3270
  const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
2504
3271
  logger.break();
2505
3272
  logger.error("Something went wrong. Please check the error below for more details.");
2506
- logger.error("If the problem persists, please open an issue on GitHub.");
3273
+ logger.error(`If the problem persists, please open an issue at ${CANONICAL_GITHUB_URL}/issues.`);
2507
3274
  logger.error("");
2508
- if (error instanceof Error) logger.error(error.message);
3275
+ logger.error(formatErrorChain(error));
2509
3276
  logger.break();
2510
3277
  if (options.shouldExit) process.exit(1);
2511
3278
  process.exitCode = 1;
2512
3279
  };
2513
3280
  //#endregion
3281
+ //#region src/utils/annotation-encoding.ts
3282
+ const encodeAnnotationProperty = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
3283
+ const encodeAnnotationMessage = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
3284
+ //#endregion
2514
3285
  //#region src/utils/select-projects.ts
2515
3286
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
2516
3287
  let packages = listWorkspacePackages(rootDirectory);
@@ -2559,7 +3330,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
2559
3330
  };
2560
3331
  //#endregion
2561
3332
  //#region src/cli.ts
2562
- const VERSION = "0.0.42";
3333
+ const VERSION = "0.0.45";
2563
3334
  const VALID_FAIL_ON_LEVELS = new Set([
2564
3335
  "error",
2565
3336
  "warning",
@@ -2572,48 +3343,104 @@ const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
2572
3343
  return diagnostics.some((diagnostic) => diagnostic.severity === "error");
2573
3344
  };
2574
3345
  const resolveFailOnLevel = (programInstance, flags, userConfig) => {
2575
- const resolvedFailOn = programInstance.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
2576
- return isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none";
3346
+ const sourceValue = programInstance.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
3347
+ if (isValidFailOnLevel(sourceValue)) return sourceValue;
3348
+ logger.warn(`Invalid failOn level "${sourceValue}". Expected one of: error, warning, none. Falling back to "none".`);
3349
+ return "none";
2577
3350
  };
2578
- const printAnnotations = (diagnostics) => {
3351
+ const printAnnotations = (diagnostics, routeToStderr) => {
3352
+ const writeLine = routeToStderr ? (line) => process.stderr.write(`${line}\n`) : (line) => process.stdout.write(`${line}\n`);
2579
3353
  for (const diagnostic of diagnostics) {
2580
3354
  const level = diagnostic.severity === "error" ? "error" : "warning";
2581
3355
  const title = `${diagnostic.plugin}/${diagnostic.rule}`;
2582
- const fileLocation = diagnostic.line > 0 ? `file=${diagnostic.filePath},line=${diagnostic.line}` : `file=${diagnostic.filePath}`;
2583
- console.log(`::${level} ${fileLocation},title=${title}::${diagnostic.message}`);
3356
+ writeLine(`::${level} ${`file=${encodeAnnotationProperty(diagnostic.filePath)}`}${diagnostic.line > 0 ? `,line=${diagnostic.line}` : ""}${`,title=${encodeAnnotationProperty(title)}`}::${encodeAnnotationMessage(diagnostic.message)}`);
2584
3357
  }
2585
3358
  };
3359
+ let isJsonModeActive = false;
3360
+ let resolvedDirectoryForCancel = null;
3361
+ let cancelStartTime = 0;
3362
+ let currentReportMode = "full";
2586
3363
  const exitGracefully = () => {
3364
+ if (isJsonModeActive) {
3365
+ writeJsonReport(buildJsonReportError({
3366
+ version: VERSION,
3367
+ directory: resolvedDirectoryForCancel ?? process.cwd(),
3368
+ error: /* @__PURE__ */ new Error("Scan cancelled by user (SIGINT/SIGTERM)"),
3369
+ elapsedMilliseconds: performance.now() - cancelStartTime,
3370
+ mode: currentReportMode
3371
+ }));
3372
+ process.exit(130);
3373
+ }
2587
3374
  logger.break();
2588
3375
  logger.log("Cancelled.");
2589
3376
  logger.break();
2590
- process.exit(0);
3377
+ process.exit(130);
2591
3378
  };
2592
3379
  process.on("SIGINT", exitGracefully);
2593
3380
  process.on("SIGTERM", exitGracefully);
2594
- const AUTOMATED_ENVIRONMENT_VARIABLES = [
3381
+ const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
2595
3382
  "CI",
3383
+ "GITHUB_ACTIONS",
3384
+ "GITLAB_CI",
3385
+ "BUILDKITE",
3386
+ "JENKINS_URL",
3387
+ "TF_BUILD",
3388
+ "CODEBUILD_BUILD_ID",
3389
+ "TEAMCITY_VERSION",
3390
+ "BITBUCKET_BUILD_NUMBER",
3391
+ "CIRCLECI",
3392
+ "TRAVIS",
3393
+ "DRONE",
2596
3394
  "CLAUDECODE",
3395
+ "CLAUDE_CODE",
2597
3396
  "CURSOR_AGENT",
2598
3397
  "CODEX_CI",
2599
3398
  "OPENCODE",
2600
3399
  "AMP_HOME"
2601
3400
  ];
2602
- const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
3401
+ const CI_ENVIRONMENT_VARIABLES = [
3402
+ "GITHUB_ACTIONS",
3403
+ "GITLAB_CI",
3404
+ "CIRCLECI"
3405
+ ];
3406
+ const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
3407
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
2603
3408
  const resolveCliScanOptions = (flags, userConfig, programInstance) => {
2604
3409
  const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
2605
3410
  return {
2606
3411
  lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? true,
2607
3412
  deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? true,
2608
- verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
3413
+ verbose: isCliOverride("verbose") ? flags.verbose : userConfig?.verbose ?? false,
2609
3414
  scoreOnly: flags.score,
2610
- offline: flags.offline
3415
+ offline: flags.offline || isCiEnvironment(),
3416
+ silent: flags.json,
3417
+ respectInlineDisables: isCliOverride("respectInlineDisables") ? flags.respectInlineDisables : userConfig?.respectInlineDisables ?? true
2611
3418
  };
2612
3419
  };
2613
- const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
3420
+ let isCompactJsonOutput = false;
3421
+ const writeJsonReport = (report) => {
3422
+ const serialized = isCompactJsonOutput ? JSON.stringify(report) : JSON.stringify(report, null, 2);
3423
+ process.stdout.write(`${serialized}\n`);
3424
+ };
3425
+ const coerceDiffValue = (value) => {
3426
+ if (value === void 0) return void 0;
3427
+ if (typeof value === "boolean") return value;
3428
+ if (typeof value === "string") {
3429
+ if (value.length === 0) return void 0;
3430
+ if (value === "false") return false;
3431
+ if (value === "true") return true;
3432
+ return value;
3433
+ }
3434
+ process.stderr.write(`[react-doctor] invalid diff value (expected boolean or string): ${typeof value}. Falling back to no diff.\n`);
3435
+ };
3436
+ const resolveEffectiveDiff = (flags, userConfig, programInstance) => {
3437
+ if (flags.full) return false;
3438
+ return coerceDiffValue(programInstance.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff);
3439
+ };
3440
+ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQuiet) => {
2614
3441
  if (effectiveDiff !== void 0 && effectiveDiff !== false) {
2615
3442
  if (diffInfo) return true;
2616
- if (!isScoreOnly) {
3443
+ if (!isQuiet) {
2617
3444
  logger.warn("No feature branch or uncommitted changes detected. Running full scan.");
2618
3445
  logger.break();
2619
3446
  }
@@ -2623,81 +3450,139 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2623
3450
  const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
2624
3451
  if (changedSourceFiles.length === 0) return false;
2625
3452
  if (shouldSkipPrompts) return false;
2626
- if (isScoreOnly) return false;
3453
+ if (isQuiet) return false;
2627
3454
  const { shouldScanChangedOnly } = await prompts({
2628
3455
  type: "confirm",
2629
3456
  name: "shouldScanChangedOnly",
2630
- message: diffInfo.isCurrentChanges ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan current changes?` : `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} changed files vs ${diffInfo.baseBranch}). Only scan this branch?`,
3457
+ message: diffInfo.isCurrentChanges ? `Found ${changedSourceFiles.length} uncommitted changed files. Only scan those?` : `On branch ${diffInfo.currentBranch} (${changedSourceFiles.length} files changed vs ${diffInfo.baseBranch}). Only scan changed files?`,
2631
3458
  initial: true
2632
3459
  });
2633
3460
  return Boolean(shouldScanChangedOnly);
2634
3461
  };
2635
- 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("-n, --no", "skip prompts, always run a full scan (decline diff-only)").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("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "none").option("--annotations", "output diagnostics as GitHub Actions annotations").action(async (directory, flags) => {
3462
+ const validateModeFlags = (flags) => {
3463
+ const coercedDiff = coerceDiffValue(flags.diff);
3464
+ const exclusiveModes = [flags.staged ? "--staged" : null, coercedDiff !== void 0 && coercedDiff !== false ? "--diff" : null].filter((modeName) => modeName !== null);
3465
+ if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
3466
+ if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
3467
+ if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
3468
+ if (flags.annotations && (flags.json || flags.score)) throw new Error("--annotations cannot be combined with --json or --score.");
3469
+ };
3470
+ 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("--json", "output a single structured JSON report (suppresses other output)").option("--json-compact", "with --json, emit compact JSON (no indentation)").option("-y, --yes", "skip prompts, scan all workspace projects").option("--full", "force a full scan (overrides any `diff` value in config or `--diff`)").option("--project <name>", "select workspace project (comma-separated for multiple)").option("--diff [base]", "scan only files changed vs base branch (pass `false` to disable; overridden by --full)").option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)").option("--staged", "scan only staged (git index) files for pre-commit hooks").option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "error").option("--annotations", "output diagnostics as GitHub Actions annotations").option("--respect-inline-disables", "respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)").option("--no-respect-inline-disables", "audit mode: neutralize inline lint suppressions before scanning").action(async (directory, flags) => {
2636
3471
  const isScoreOnly = flags.score;
3472
+ const isJsonMode = flags.json;
3473
+ const isQuiet = isScoreOnly || isJsonMode;
3474
+ const resolvedDirectory = path.resolve(directory);
3475
+ const jsonStartTime = performance.now();
3476
+ isJsonModeActive = isJsonMode;
3477
+ isCompactJsonOutput = Boolean(flags.jsonCompact);
3478
+ resolvedDirectoryForCancel = resolvedDirectory;
3479
+ cancelStartTime = jsonStartTime;
3480
+ if (isJsonMode) setLoggerSilent(true);
2637
3481
  try {
2638
- const resolvedDirectory = path.resolve(directory);
3482
+ validateModeFlags(flags);
2639
3483
  const userConfig = loadConfig(resolvedDirectory);
2640
- if (!isScoreOnly) {
3484
+ if (!isQuiet) {
2641
3485
  logger.log(`react-doctor v${VERSION}`);
2642
3486
  logger.break();
2643
3487
  }
2644
3488
  const scanOptions = resolveCliScanOptions(flags, userConfig, program);
2645
- const shouldSkipPrompts = flags.yes || flags.no || isAutomatedEnvironment() || !process.stdin.isTTY;
3489
+ const shouldSkipPrompts = flags.yes || flags.full || isJsonMode || isNonInteractiveEnvironment() || !process.stdin.isTTY;
3490
+ if (!flags.offline && isCiEnvironment() && !isQuiet) {
3491
+ logger.dim("CI detected — scoring locally.");
3492
+ logger.break();
3493
+ }
2646
3494
  if (flags.staged) {
3495
+ currentReportMode = "staged";
2647
3496
  const stagedFiles = getStagedSourceFiles(resolvedDirectory);
2648
3497
  if (stagedFiles.length === 0) {
2649
- if (!isScoreOnly) logger.dim("No staged source files found.");
3498
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3499
+ version: VERSION,
3500
+ directory: resolvedDirectory,
3501
+ mode: "staged",
3502
+ diff: null,
3503
+ scans: [],
3504
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
3505
+ }));
3506
+ else if (!isScoreOnly) logger.dim("No staged source files found.");
2650
3507
  return;
2651
3508
  }
2652
- if (!isScoreOnly) {
3509
+ if (!isQuiet) {
2653
3510
  logger.log(`Scanning ${highlighter.info(`${stagedFiles.length}`)} staged files...`);
2654
3511
  logger.break();
2655
3512
  }
2656
- const snapshot = materializeStagedFiles(resolvedDirectory, stagedFiles, mkdtempSync(path.join(tmpdir(), "react-doctor-staged-")));
3513
+ let tempDirectory = null;
3514
+ let cleanupSnapshot = null;
2657
3515
  try {
2658
- const remappedDiagnostics = (await scan(snapshot.tempDirectory, {
3516
+ tempDirectory = mkdtempSync(path.join(tmpdir(), "react-doctor-staged-"));
3517
+ const snapshot = materializeStagedFiles(resolvedDirectory, stagedFiles, tempDirectory);
3518
+ cleanupSnapshot = snapshot.cleanup;
3519
+ const scanResult = await scan(snapshot.tempDirectory, {
2659
3520
  ...scanOptions,
2660
3521
  includePaths: snapshot.stagedFiles,
2661
3522
  configOverride: userConfig
2662
- })).diagnostics.map((diagnostic) => ({
3523
+ });
3524
+ const remappedDiagnostics = scanResult.diagnostics.map((diagnostic) => ({
2663
3525
  ...diagnostic,
2664
- filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replace(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
3526
+ filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
2665
3527
  }));
2666
- if (flags.annotations) printAnnotations(remappedDiagnostics);
3528
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3529
+ version: VERSION,
3530
+ directory: resolvedDirectory,
3531
+ mode: "staged",
3532
+ diff: null,
3533
+ scans: [{
3534
+ directory: resolvedDirectory,
3535
+ result: {
3536
+ ...scanResult,
3537
+ diagnostics: remappedDiagnostics,
3538
+ project: {
3539
+ ...scanResult.project,
3540
+ rootDirectory: resolvedDirectory
3541
+ }
3542
+ }
3543
+ }],
3544
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
3545
+ }));
3546
+ if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
2667
3547
  if (shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
2668
3548
  } finally {
2669
- snapshot.cleanup();
3549
+ cleanupSnapshot?.();
2670
3550
  }
2671
3551
  return;
2672
3552
  }
2673
3553
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
2674
- const effectiveDiff = program.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff;
3554
+ const effectiveDiff = resolveEffectiveDiff(flags, userConfig, program);
2675
3555
  const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
2676
- const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
2677
- const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
2678
- if (isDiffMode && diffInfo && !isScoreOnly) {
3556
+ const diffInfo = effectiveDiff !== void 0 && effectiveDiff !== false || !shouldSkipPrompts && !isQuiet ? getDiffInfo(resolvedDirectory, explicitBaseBranch) : null;
3557
+ const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isQuiet);
3558
+ currentReportMode = isDiffMode ? "diff" : "full";
3559
+ if (isDiffMode && diffInfo && !isQuiet) {
2679
3560
  if (diffInfo.isCurrentChanges) logger.log("Scanning uncommitted changes");
2680
3561
  else logger.log(`Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`);
2681
3562
  logger.break();
2682
3563
  }
2683
3564
  const allDiagnostics = [];
3565
+ const completedScans = [];
2684
3566
  for (const projectDirectory of projectDirectories) {
2685
3567
  let includePaths;
2686
3568
  if (isDiffMode) {
2687
- const projectDiffInfo = getDiffInfo(projectDirectory, explicitBaseBranch);
3569
+ const projectDiffInfo = projectDirectory === resolvedDirectory ? diffInfo : getDiffInfo(projectDirectory, explicitBaseBranch);
2688
3570
  if (projectDiffInfo) {
2689
3571
  const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles);
2690
3572
  if (changedSourceFiles.length === 0) {
2691
- if (!isScoreOnly) {
3573
+ if (!isQuiet) {
2692
3574
  logger.dim(`No changed source files in ${projectDirectory}, skipping.`);
2693
3575
  logger.break();
2694
3576
  }
2695
3577
  continue;
2696
3578
  }
2697
3579
  includePaths = changedSourceFiles;
3580
+ } else if (!isQuiet) {
3581
+ logger.dim(`Cannot detect diff for ${projectDirectory} (not a git repository?) — scanning all files.`);
3582
+ logger.break();
2698
3583
  }
2699
3584
  }
2700
- if (!isScoreOnly) {
3585
+ if (!isQuiet) {
2701
3586
  logger.dim(`Scanning ${projectDirectory}...`);
2702
3587
  logger.break();
2703
3588
  }
@@ -2706,28 +3591,80 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2706
3591
  includePaths
2707
3592
  });
2708
3593
  allDiagnostics.push(...scanResult.diagnostics);
2709
- if (!isScoreOnly) logger.break();
3594
+ completedScans.push({
3595
+ directory: projectDirectory,
3596
+ result: scanResult
3597
+ });
3598
+ if (!isQuiet) logger.break();
2710
3599
  }
2711
- if (flags.annotations) printAnnotations(allDiagnostics);
3600
+ const reportMode = isDiffMode ? "diff" : "full";
3601
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3602
+ version: VERSION,
3603
+ directory: resolvedDirectory,
3604
+ mode: reportMode,
3605
+ diff: isDiffMode ? diffInfo : null,
3606
+ scans: completedScans,
3607
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
3608
+ }));
3609
+ if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
2712
3610
  if (shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
2713
3611
  } catch (error) {
2714
- handleError(error);
3612
+ try {
3613
+ if (isJsonMode) {
3614
+ writeJsonReport(buildJsonReportError({
3615
+ version: VERSION,
3616
+ directory: resolvedDirectory,
3617
+ error,
3618
+ elapsedMilliseconds: performance.now() - jsonStartTime,
3619
+ mode: currentReportMode
3620
+ }));
3621
+ process.exitCode = 1;
3622
+ return;
3623
+ }
3624
+ handleError(error);
3625
+ } catch {
3626
+ if (isJsonMode) process.stdout.write("{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n");
3627
+ process.exitCode = 1;
3628
+ }
2715
3629
  }
2716
3630
  }).addHelpText("after", `
3631
+ ${highlighter.dim("Configuration:")}
3632
+ Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
3633
+ CLI flags always override config values. See the README for the full schema.
3634
+
2717
3635
  ${highlighter.dim("Learn more:")}
2718
- ${highlighter.info("https://github.com/millionco/react-doctor")}
3636
+ ${highlighter.info(CANONICAL_GITHUB_URL)}
2719
3637
  `);
2720
- program.command("install").description("Install the react-doctor skill into your coding agents").option("-y, --yes", "skip prompts, install for all detected agents").action(async (options) => {
3638
+ program.command("install").description("Install the react-doctor skill into your coding agents").option("-y, --yes", "skip prompts, install for all detected agents").option("--dry-run", "show what would be installed without writing files").action(async (options) => {
2721
3639
  try {
2722
- await runInstallSkill({ yes: options.yes });
3640
+ await runInstallSkill({
3641
+ yes: options.yes,
3642
+ dryRun: options.dryRun
3643
+ });
2723
3644
  } catch (error) {
2724
3645
  handleError(error);
2725
3646
  }
2726
3647
  });
2727
- const main$1 = async () => {
2728
- await program.parseAsync();
2729
- };
2730
- main$1();
3648
+ process.stdout.on("error", (error) => {
3649
+ if (error.code === "EPIPE") process.exit(0);
3650
+ });
3651
+ program.parseAsync().catch((error) => {
3652
+ if (isJsonModeActive) {
3653
+ try {
3654
+ writeJsonReport(buildJsonReportError({
3655
+ version: VERSION,
3656
+ directory: resolvedDirectoryForCancel ?? process.cwd(),
3657
+ error,
3658
+ elapsedMilliseconds: performance.now() - cancelStartTime,
3659
+ mode: currentReportMode
3660
+ }));
3661
+ } catch {
3662
+ process.stdout.write("{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n");
3663
+ }
3664
+ process.exit(1);
3665
+ }
3666
+ handleError(error);
3667
+ });
2731
3668
  //#endregion
2732
3669
  export {};
2733
3670