react-doctor 0.0.41 → 0.0.44

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,19 +1,51 @@
1
- #!/usr/bin/env node
2
1
  import { createRequire } from "node:module";
3
2
  import fs, { accessSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, 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
8
  import pc from "picocolors";
9
9
  import basePrompts from "prompts";
10
10
  import ora from "ora";
11
11
  import { randomUUID } from "node:crypto";
12
- import { performance } from "node:perf_hooks";
13
- import { execSync, spawn, spawnSync } from "node:child_process";
12
+ import { spawn, spawnSync } from "node:child_process";
14
13
  import { main } from "knip";
15
14
  import { createOptions } from "knip/session";
16
-
15
+ //#region src/constants.ts
16
+ const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
17
+ const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
18
+ const MILLISECONDS_PER_SECOND = 1e3;
19
+ const SCORE_API_URL = "https://www.react.doctor/api/score";
20
+ const SHARE_BASE_URL = "https://www.react.doctor/share";
21
+ const FETCH_TIMEOUT_MS = 1e4;
22
+ const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
23
+ const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
24
+ const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
25
+ const ERROR_RULE_PENALTY = 1.5;
26
+ const WARNING_RULE_PENALTY = .75;
27
+ const KNIP_CONFIG_LOCATIONS = [
28
+ "knip.json",
29
+ "knip.jsonc",
30
+ ".knip.json",
31
+ ".knip.jsonc",
32
+ "knip.ts",
33
+ "knip.js",
34
+ "knip.config.ts",
35
+ "knip.config.js"
36
+ ];
37
+ const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
38
+ const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
39
+ const IGNORED_DIRECTORIES = new Set([
40
+ "node_modules",
41
+ "dist",
42
+ "build",
43
+ "coverage"
44
+ ]);
45
+ const CANONICAL_GITHUB_URL = "https://github.com/millionco/react-doctor";
46
+ const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
47
+ const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
48
+ //#endregion
17
49
  //#region src/utils/detect-agents.ts
18
50
  const AGENTS_SKILL_DIR = ".agents/skills";
19
51
  const SUPPORTED_AGENTS = {
@@ -75,7 +107,6 @@ const isCommandAvailable = (command) => {
75
107
  const detectAvailableAgents = () => ALL_SUPPORTED_AGENTS.filter((agent) => SUPPORTED_AGENTS[agent].binaries.some(isCommandAvailable));
76
108
  const toDisplayName = (agent) => SUPPORTED_AGENTS[agent].displayName;
77
109
  const toSkillDir = (agent) => SUPPORTED_AGENTS[agent].skillDir;
78
-
79
110
  //#endregion
80
111
  //#region src/utils/highlighter.ts
81
112
  const highlighter = {
@@ -85,7 +116,6 @@ const highlighter = {
85
116
  success: pc.green,
86
117
  dim: pc.dim
87
118
  };
88
-
89
119
  //#endregion
90
120
  //#region src/utils/install-skill-for-agent.ts
91
121
  const installSkillForAgent = (projectRoot, agent, skillSourceDirectory, skillName, alreadyInstalledDirectories) => {
@@ -99,33 +129,43 @@ const installSkillForAgent = (projectRoot, agent, skillSourceDirectory, skillNam
99
129
  cpSync(skillSourceDirectory, installedSkillDirectory, { recursive: true });
100
130
  return installedSkillDirectory;
101
131
  };
102
-
103
132
  //#endregion
104
133
  //#region src/utils/logger.ts
134
+ let isSilent$1 = false;
135
+ const setLoggerSilent = (silent) => {
136
+ isSilent$1 = silent;
137
+ };
138
+ const isLoggerSilent = () => isSilent$1;
105
139
  const logger = {
106
140
  error(...args) {
107
- console.log(highlighter.error(args.join(" ")));
141
+ if (isSilent$1) return;
142
+ console.error(highlighter.error(args.join(" ")));
108
143
  },
109
144
  warn(...args) {
110
- console.log(highlighter.warn(args.join(" ")));
145
+ if (isSilent$1) return;
146
+ console.warn(highlighter.warn(args.join(" ")));
111
147
  },
112
148
  info(...args) {
149
+ if (isSilent$1) return;
113
150
  console.log(highlighter.info(args.join(" ")));
114
151
  },
115
152
  success(...args) {
153
+ if (isSilent$1) return;
116
154
  console.log(highlighter.success(args.join(" ")));
117
155
  },
118
156
  dim(...args) {
157
+ if (isSilent$1) return;
119
158
  console.log(highlighter.dim(args.join(" ")));
120
159
  },
121
160
  log(...args) {
161
+ if (isSilent$1) return;
122
162
  console.log(args.join(" "));
123
163
  },
124
164
  break() {
165
+ if (isSilent$1) return;
125
166
  console.log("");
126
167
  }
127
168
  };
128
-
129
169
  //#endregion
130
170
  //#region src/utils/should-auto-select-current-choice.ts
131
171
  const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
@@ -133,26 +173,20 @@ const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
133
173
  const currentChoice = choiceStates[cursor];
134
174
  return Boolean(currentChoice) && !currentChoice.disabled;
135
175
  };
136
-
137
176
  //#endregion
138
177
  //#region src/utils/should-select-all-choices.ts
139
178
  const shouldSelectAllChoices = (choiceStates) => {
140
179
  return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
141
180
  };
142
-
143
181
  //#endregion
144
182
  //#region src/utils/prompts.ts
145
183
  const require = createRequire(import.meta.url);
146
184
  const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
147
- const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
148
185
  let didPatchMultiselectToggleAll = false;
149
186
  let didPatchMultiselectSubmit = false;
150
- let didPatchSelectBanner = false;
151
- const selectBannerMap = /* @__PURE__ */ new Map();
152
187
  const onCancel = () => {
153
188
  logger.break();
154
189
  logger.log("Cancelled.");
155
- logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
156
190
  logger.break();
157
191
  process.exit(0);
158
192
  };
@@ -184,37 +218,30 @@ const patchMultiselectSubmit = () => {
184
218
  originalSubmit.call(this);
185
219
  };
186
220
  };
187
- const patchSelectBanner = () => {
188
- if (didPatchSelectBanner) return;
189
- didPatchSelectBanner = true;
190
- const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
191
- const promptsClear = require("prompts/lib/util/clear");
192
- const originalRender = selectConstructor.prototype.render;
193
- selectConstructor.prototype.render = function() {
194
- originalRender.call(this);
195
- const banner = selectBannerMap.get(this.cursor);
196
- if (!banner || this.closed || this.done) return;
197
- this.out.write(promptsClear(this.outputText, this.out.columns));
198
- this.outputText = `${banner}\n\n${this.outputText}`;
199
- this.out.write(this.outputText);
200
- };
201
- };
202
221
  const prompts = (questions) => {
203
222
  patchMultiselectToggleAll();
204
223
  patchMultiselectSubmit();
205
- patchSelectBanner();
206
224
  return basePrompts(questions, { onCancel });
207
225
  };
208
-
209
226
  //#endregion
210
227
  //#region src/utils/spinner.ts
211
228
  let sharedInstance = null;
212
229
  let activeCount = 0;
213
230
  const pendingTexts = /* @__PURE__ */ new Set();
231
+ const finalizedHandles = /* @__PURE__ */ new WeakSet();
232
+ let isSilent = false;
233
+ const setSpinnerSilent = (silent) => {
234
+ isSilent = silent;
235
+ };
236
+ const isSpinnerSilent = () => isSilent;
237
+ const noopHandle = Object.freeze({
238
+ succeed: () => {},
239
+ fail: () => {}
240
+ });
214
241
  const finalize = (method, originalText, displayText) => {
215
242
  pendingTexts.delete(originalText);
216
- activeCount--;
217
- if (activeCount <= 0 || !sharedInstance) {
243
+ activeCount = Math.max(0, activeCount - 1);
244
+ if (activeCount === 0 || !sharedInstance) {
218
245
  sharedInstance?.[method](displayText);
219
246
  sharedInstance = null;
220
247
  activeCount = 0;
@@ -227,16 +254,25 @@ const finalize = (method, originalText, displayText) => {
227
254
  sharedInstance.start();
228
255
  };
229
256
  const spinner = (text) => ({ start() {
257
+ if (isSilent) return noopHandle;
230
258
  activeCount++;
231
259
  pendingTexts.add(text);
232
260
  if (!sharedInstance) sharedInstance = ora({ text }).start();
233
261
  else sharedInstance.text = text;
234
- return {
235
- succeed: (displayText) => finalize("succeed", text, displayText),
236
- fail: (displayText) => finalize("fail", text, displayText)
262
+ const handle = {
263
+ succeed: (displayText) => {
264
+ if (finalizedHandles.has(handle)) return;
265
+ finalizedHandles.add(handle);
266
+ finalize("succeed", text, displayText);
267
+ },
268
+ fail: (displayText) => {
269
+ if (finalizedHandles.has(handle)) return;
270
+ finalizedHandles.add(handle);
271
+ finalize("fail", text, displayText);
272
+ }
237
273
  };
274
+ return handle;
238
275
  } });
239
-
240
276
  //#endregion
241
277
  //#region src/install-skill.ts
242
278
  const SKILL_NAME = "react-doctor";
@@ -272,6 +308,12 @@ const runInstallSkill = async (options = {}) => {
272
308
  min: 1
273
309
  })).agents ?? [];
274
310
  if (selectedAgents.length === 0) return;
311
+ if (options.dryRun) {
312
+ logger.log(`Dry run — would install ${SKILL_NAME} skill for:`);
313
+ for (const agent of selectedAgents) logger.dim(` - ${toDisplayName(agent)}`);
314
+ logger.dim(` Source: ${sourceDir}`);
315
+ return;
316
+ }
275
317
  const installSpinner = spinner(`Installing ${SKILL_NAME} skill...`).start();
276
318
  const installedDirectories = /* @__PURE__ */ new Set();
277
319
  for (const agent of selectedAgents) {
@@ -280,45 +322,11 @@ const runInstallSkill = async (options = {}) => {
280
322
  }
281
323
  installSpinner.succeed(`${SKILL_NAME} skill installed for ${selectedAgents.map(toDisplayName).join(", ")}.`);
282
324
  };
283
-
284
- //#endregion
285
- //#region src/constants.ts
286
- const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
287
- const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
288
- const MILLISECONDS_PER_SECOND = 1e3;
289
- const ERROR_PREVIEW_LENGTH_CHARS = 200;
290
- const PERFECT_SCORE = 100;
291
- const SCORE_GOOD_THRESHOLD = 75;
292
- const SCORE_OK_THRESHOLD = 50;
293
- const SCORE_BAR_WIDTH_CHARS = 50;
294
- const SUMMARY_BOX_HORIZONTAL_PADDING_CHARS = 1;
295
- const SUMMARY_BOX_OUTER_INDENT_CHARS = 2;
296
- const SCORE_API_URL = "https://www.react.doctor/api/score";
297
- const SHARE_BASE_URL = "https://www.react.doctor/share";
298
- const FETCH_TIMEOUT_MS = 1e4;
299
- const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
300
- const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
301
- const OXLINT_MAX_FILES_PER_BATCH = 500;
302
- const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
303
- const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
304
- const ERROR_RULE_PENALTY = 1.5;
305
- const WARNING_RULE_PENALTY = .75;
306
- const MAX_KNIP_RETRIES = 5;
307
- const OXLINT_NODE_REQUIREMENT = "^20.19.0 || >=22.12.0";
308
- const OXLINT_RECOMMENDED_NODE_MAJOR = 24;
309
- const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
310
- const IGNORED_DIRECTORIES = new Set([
311
- "node_modules",
312
- "dist",
313
- "build",
314
- "coverage"
315
- ]);
316
-
317
325
  //#endregion
318
326
  //#region src/core/calculate-score-locally.ts
319
327
  const getScoreLabel = (score) => {
320
- if (score >= SCORE_GOOD_THRESHOLD) return "Great";
321
- if (score >= SCORE_OK_THRESHOLD) return "Needs work";
328
+ if (score >= 75) return "Great";
329
+ if (score >= 50) return "Needs work";
322
330
  return "Critical";
323
331
  };
324
332
  const countUniqueRules = (diagnostics) => {
@@ -336,7 +344,7 @@ const countUniqueRules = (diagnostics) => {
336
344
  };
337
345
  const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
338
346
  const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
339
- return Math.max(0, Math.round(PERFECT_SCORE - penalty));
347
+ return Math.max(0, Math.round(100 - penalty));
340
348
  };
341
349
  const calculateScoreLocally = (diagnostics) => {
342
350
  const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
@@ -346,7 +354,6 @@ const calculateScoreLocally = (diagnostics) => {
346
354
  label: getScoreLabel(score)
347
355
  };
348
356
  };
349
-
350
357
  //#endregion
351
358
  //#region src/core/try-score-from-api.ts
352
359
  const parseScoreResult = (value) => {
@@ -360,44 +367,51 @@ const parseScoreResult = (value) => {
360
367
  label: labelValue
361
368
  };
362
369
  };
370
+ const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
371
+ const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
372
+ const describeFailure = (error) => {
373
+ if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
374
+ if (error instanceof Error && error.message) return error.message;
375
+ return String(error);
376
+ };
363
377
  const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
378
+ if (typeof fetchImplementation !== "function") return null;
364
379
  const controller = new AbortController();
365
380
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
366
381
  try {
367
382
  const response = await fetchImplementation(SCORE_API_URL, {
368
383
  method: "POST",
369
384
  headers: { "Content-Type": "application/json" },
370
- body: JSON.stringify({ diagnostics }),
385
+ body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
371
386
  signal: controller.signal
372
387
  });
373
- if (!response.ok) return null;
388
+ if (!response.ok) {
389
+ console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
390
+ return null;
391
+ }
374
392
  return parseScoreResult(await response.json());
375
- } catch {
393
+ } catch (error) {
394
+ console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
376
395
  return null;
377
396
  } finally {
378
397
  clearTimeout(timeoutId);
379
398
  }
380
399
  };
381
-
400
+ //#endregion
401
+ //#region src/utils/calculate-score-browser.ts
402
+ const getGlobalFetch = () => typeof fetch === "function" ? fetch : void 0;
403
+ const calculateScore$1 = async (diagnostics, fetchImplementation = getGlobalFetch()) => await tryScoreFromApi(diagnostics, fetchImplementation) ?? calculateScoreLocally(diagnostics);
382
404
  //#endregion
383
405
  //#region src/utils/proxy-fetch.ts
384
406
  const getGlobalProcess = () => {
385
407
  const candidate = globalThis.process;
386
408
  return candidate?.versions?.node ? candidate : void 0;
387
409
  };
388
- const readEnvProxy = () => {
410
+ const getProxyUrl = () => {
389
411
  const proc = getGlobalProcess();
390
412
  if (!proc?.env) return void 0;
391
413
  return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
392
414
  };
393
- let isProxyUrlResolved = false;
394
- let resolvedProxyUrl;
395
- const getProxyUrl = () => {
396
- if (isProxyUrlResolved) return resolvedProxyUrl;
397
- isProxyUrlResolved = true;
398
- resolvedProxyUrl = readEnvProxy();
399
- return resolvedProxyUrl;
400
- };
401
415
  const createProxyDispatcher = async (proxyUrl) => {
402
416
  try {
403
417
  const { ProxyAgent } = await import("undici");
@@ -407,41 +421,27 @@ const createProxyDispatcher = async (proxyUrl) => {
407
421
  }
408
422
  };
409
423
  const proxyFetch = async (url, init) => {
410
- const controller = new AbortController();
411
- const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
412
- try {
413
- const proxyUrl = getProxyUrl();
414
- const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
415
- return await fetch(url, {
416
- ...init,
417
- signal: controller.signal,
418
- ...dispatcher ? { dispatcher } : {}
419
- });
420
- } finally {
421
- clearTimeout(timeoutId);
422
- }
424
+ const proxyUrl = getProxyUrl();
425
+ const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
426
+ const fetchInit = {
427
+ ...init,
428
+ ...dispatcher ? { dispatcher } : {}
429
+ };
430
+ return fetch(url, fetchInit);
423
431
  };
424
-
425
432
  //#endregion
426
433
  //#region src/utils/calculate-score-node.ts
427
- const calculateScore = async (diagnostics) => {
428
- const apiScore = await tryScoreFromApi(diagnostics, proxyFetch);
429
- if (apiScore) return apiScore;
430
- return calculateScoreLocally(diagnostics);
431
- };
432
-
434
+ const calculateScore = (diagnostics) => calculateScore$1(diagnostics, proxyFetch);
433
435
  //#endregion
434
436
  //#region src/utils/colorize-by-score.ts
435
437
  const colorizeByScore = (text, score) => {
436
- if (score >= SCORE_GOOD_THRESHOLD) return highlighter.success(text);
437
- if (score >= SCORE_OK_THRESHOLD) return highlighter.warn(text);
438
+ if (score >= 75) return highlighter.success(text);
439
+ if (score >= 50) return highlighter.warn(text);
438
440
  return highlighter.error(text);
439
441
  };
440
-
441
442
  //#endregion
442
443
  //#region src/plugin/constants.ts
443
444
  const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
444
-
445
445
  //#endregion
446
446
  //#region src/utils/is-file.ts
447
447
  const isFile = (filePath) => {
@@ -451,10 +451,10 @@ const isFile = (filePath) => {
451
451
  return false;
452
452
  }
453
453
  };
454
-
455
454
  //#endregion
456
455
  //#region src/utils/read-package-json.ts
457
- const readPackageJson = (packageJsonPath) => {
456
+ const cachedPackageJsons = /* @__PURE__ */ new Map();
457
+ const readPackageJsonUncached = (packageJsonPath) => {
458
458
  try {
459
459
  return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
460
460
  } catch (error) {
@@ -466,11 +466,25 @@ const readPackageJson = (packageJsonPath) => {
466
466
  throw error;
467
467
  }
468
468
  };
469
-
469
+ const readPackageJson = (packageJsonPath) => {
470
+ const absolutePath = path.resolve(packageJsonPath);
471
+ const cached = cachedPackageJsons.get(absolutePath);
472
+ if (cached !== void 0) return cached;
473
+ const result = readPackageJsonUncached(absolutePath);
474
+ cachedPackageJsons.set(absolutePath, result);
475
+ return result;
476
+ };
470
477
  //#endregion
471
478
  //#region src/utils/check-reduced-motion.ts
472
479
  const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
473
- const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
480
+ const REDUCED_MOTION_FILE_GLOBS = [
481
+ "*.ts",
482
+ "*.tsx",
483
+ "*.js",
484
+ "*.jsx",
485
+ "*.css",
486
+ "*.scss"
487
+ ];
474
488
  const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
475
489
  filePath: "package.json",
476
490
  plugin: "react-doctor",
@@ -480,8 +494,7 @@ const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
480
494
  help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
481
495
  line: 0,
482
496
  column: 0,
483
- category: "Accessibility",
484
- weight: 2
497
+ category: "Accessibility"
485
498
  };
486
499
  const checkReducedMotion = (rootDirectory) => {
487
500
  const packageJsonPath = path.join(rootDirectory, "package.json");
@@ -498,17 +511,25 @@ const checkReducedMotion = (rootDirectory) => {
498
511
  return [];
499
512
  }
500
513
  if (!hasMotionLibrary) return [];
501
- try {
502
- execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
503
- cwd: rootDirectory,
504
- stdio: "pipe"
505
- });
506
- return [];
507
- } catch {
508
- return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
509
- }
514
+ const result = spawnSync("git", [
515
+ "grep",
516
+ "-ql",
517
+ "-E",
518
+ REDUCED_MOTION_GREP_PATTERN,
519
+ "--",
520
+ ...REDUCED_MOTION_FILE_GLOBS
521
+ ], {
522
+ cwd: rootDirectory,
523
+ stdio: [
524
+ "ignore",
525
+ "pipe",
526
+ "pipe"
527
+ ]
528
+ });
529
+ if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
530
+ if (result.status === 0) return [];
531
+ return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
510
532
  };
511
-
512
533
  //#endregion
513
534
  //#region src/utils/read-file-lines-node.ts
514
535
  const createNodeReadFileLinesSync = (rootDirectory) => {
@@ -521,7 +542,6 @@ const createNodeReadFileLinesSync = (rootDirectory) => {
521
542
  }
522
543
  };
523
544
  };
524
-
525
545
  //#endregion
526
546
  //#region src/utils/match-glob-pattern.ts
527
547
  const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
@@ -549,7 +569,6 @@ const compileGlobPattern = (pattern) => {
549
569
  regexSource += "$";
550
570
  return new RegExp(regexSource);
551
571
  };
552
-
553
572
  //#endregion
554
573
  //#region src/utils/is-ignored-file.ts
555
574
  const toRelativePath = (filePath, rootDirectory) => {
@@ -558,13 +577,16 @@ const toRelativePath = (filePath, rootDirectory) => {
558
577
  if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
559
578
  return normalizedFilePath.replace(/^\.\//, "");
560
579
  };
561
- const compileIgnoredFilePatterns = (userConfig) => Array.isArray(userConfig?.ignore?.files) ? userConfig.ignore.files.map(compileGlobPattern) : [];
580
+ const compileIgnoredFilePatterns = (userConfig) => {
581
+ const files = userConfig?.ignore?.files;
582
+ if (!Array.isArray(files)) return [];
583
+ return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
584
+ };
562
585
  const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
563
586
  if (patterns.length === 0) return false;
564
587
  const relativePath = toRelativePath(filePath, rootDirectory);
565
588
  return patterns.some((pattern) => pattern.test(relativePath));
566
589
  };
567
-
568
590
  //#endregion
569
591
  //#region src/utils/filter-diagnostics.ts
570
592
  const resolveCandidateReadPath = (rootDirectory, filePath) => {
@@ -600,9 +622,9 @@ const isRuleSuppressed = (commentRules, ruleId) => {
600
622
  return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
601
623
  };
602
624
  const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
603
- const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
625
+ const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
604
626
  const ignoredFilePatterns = compileIgnoredFilePatterns(config);
605
- const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents : []);
627
+ const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
606
628
  const hasTextComponents = textComponentNames.size > 0;
607
629
  const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
608
630
  return diagnostics.filter((diagnostic) => {
@@ -638,20 +660,15 @@ const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync)
638
660
  return true;
639
661
  });
640
662
  };
641
-
642
663
  //#endregion
643
664
  //#region src/utils/merge-and-filter-diagnostics.ts
644
665
  const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync) => {
645
666
  return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
646
667
  };
647
-
648
- //#endregion
649
- //#region src/utils/jsx-include-paths.ts
650
- const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
651
-
652
668
  //#endregion
653
669
  //#region src/utils/combine-diagnostics.ts
654
- const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true) => {
670
+ const combineDiagnostics = (input) => {
671
+ const { lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true } = input;
655
672
  const extraDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
656
673
  return mergeAndFilterDiagnostics([
657
674
  ...lintDiagnostics,
@@ -659,7 +676,9 @@ const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isD
659
676
  ...extraDiagnostics
660
677
  ], directory, userConfig, readFileLinesSync);
661
678
  };
662
-
679
+ //#endregion
680
+ //#region src/utils/jsx-include-paths.ts
681
+ const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
663
682
  //#endregion
664
683
  //#region src/utils/find-monorepo-root.ts
665
684
  const isMonorepoRoot = (directory) => {
@@ -678,11 +697,13 @@ const findMonorepoRoot = (startDirectory) => {
678
697
  }
679
698
  return null;
680
699
  };
681
-
682
700
  //#endregion
683
701
  //#region src/utils/is-plain-object.ts
684
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
685
-
702
+ const isPlainObject = (value) => {
703
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
704
+ const prototype = Object.getPrototypeOf(value);
705
+ return prototype === null || prototype === Object.prototype;
706
+ };
686
707
  //#endregion
687
708
  //#region src/utils/discover-project.ts
688
709
  const REACT_COMPILER_PACKAGES = new Set([
@@ -690,6 +711,11 @@ const REACT_COMPILER_PACKAGES = new Set([
690
711
  "react-compiler-runtime",
691
712
  "eslint-plugin-react-compiler"
692
713
  ]);
714
+ const TANSTACK_QUERY_PACKAGES = new Set([
715
+ "@tanstack/react-query",
716
+ "@tanstack/query-core",
717
+ "react-query"
718
+ ]);
693
719
  const NEXT_CONFIG_FILENAMES = [
694
720
  "next.config.js",
695
721
  "next.config.mjs",
@@ -708,7 +734,11 @@ const VITE_CONFIG_FILENAMES = [
708
734
  "vite.config.js",
709
735
  "vite.config.ts",
710
736
  "vite.config.mjs",
711
- "vite.config.cjs"
737
+ "vite.config.mts",
738
+ "vite.config.cjs",
739
+ "vite.config.cts",
740
+ "vitest.config.ts",
741
+ "vitest.config.js"
712
742
  ];
713
743
  const EXPO_APP_CONFIG_FILENAMES = [
714
744
  "app.json",
@@ -716,7 +746,7 @@ const EXPO_APP_CONFIG_FILENAMES = [
716
746
  "app.config.ts"
717
747
  ];
718
748
  const REACT_COMPILER_PACKAGE_REFERENCE_PATTERN = /babel-plugin-react-compiler|react-compiler-runtime|eslint-plugin-react-compiler|["']react-compiler["']/;
719
- const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*true\b/;
749
+ const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*(?:true\b|\{)/;
720
750
  const FRAMEWORK_PACKAGES = {
721
751
  next: "nextjs",
722
752
  "@tanstack/react-start": "tanstack-start",
@@ -758,6 +788,7 @@ const countSourceFilesViaFilesystem = (rootDirectory) => {
758
788
  const countSourceFilesViaGit = (rootDirectory) => {
759
789
  const result = spawnSync("git", [
760
790
  "ls-files",
791
+ "-z",
761
792
  "--cached",
762
793
  "--others",
763
794
  "--exclude-standard"
@@ -767,7 +798,7 @@ const countSourceFilesViaGit = (rootDirectory) => {
767
798
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
768
799
  });
769
800
  if (result.error || result.status !== 0) return null;
770
- return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
801
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
771
802
  };
772
803
  const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
773
804
  const collectAllDependencies = (packageJson) => ({
@@ -865,17 +896,17 @@ const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogRefer
865
896
  const resolveCatalogVersion = (packageJson, packageName, rootDirectory) => {
866
897
  const rawVersion = collectAllDependencies(packageJson)[packageName];
867
898
  const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
868
- const raw = packageJson;
869
- if (isPlainObject(raw.catalog)) {
870
- const version = resolveVersionFromCatalog(raw.catalog, packageName);
899
+ if (isPlainObject(packageJson.catalog)) {
900
+ const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
871
901
  if (version) return version;
872
902
  }
873
- if (isPlainObject(raw.catalogs)) {
874
- if (catalogName && isPlainObject(raw.catalogs[catalogName])) {
875
- const version = resolveVersionFromCatalog(raw.catalogs[catalogName], packageName);
903
+ if (isPlainObject(packageJson.catalogs)) {
904
+ const namedCatalog = catalogName ? packageJson.catalogs[catalogName] : void 0;
905
+ if (namedCatalog && isPlainObject(namedCatalog)) {
906
+ const version = resolveVersionFromCatalog(namedCatalog, packageName);
876
907
  if (version) return version;
877
908
  }
878
- for (const catalogEntries of Object.values(raw.catalogs)) if (isPlainObject(catalogEntries)) {
909
+ for (const catalogEntries of Object.values(packageJson.catalogs)) if (isPlainObject(catalogEntries)) {
879
910
  const version = resolveVersionFromCatalog(catalogEntries, packageName);
880
911
  if (version) return version;
881
912
  }
@@ -916,11 +947,32 @@ const parsePnpmWorkspacePatterns = (rootDirectory) => {
916
947
  }
917
948
  return patterns;
918
949
  };
950
+ const NX_PROJECT_DISCOVERY_DIRS = [
951
+ "apps",
952
+ "libs",
953
+ "packages"
954
+ ];
955
+ const getNxWorkspaceDirectories = (rootDirectory) => {
956
+ if (!isFile(path.join(rootDirectory, "nx.json"))) return [];
957
+ const collected = [];
958
+ for (const candidate of NX_PROJECT_DISCOVERY_DIRS) {
959
+ const candidatePath = path.join(rootDirectory, candidate);
960
+ if (!fs.existsSync(candidatePath) || !fs.statSync(candidatePath).isDirectory()) continue;
961
+ for (const entry of fs.readdirSync(candidatePath, { withFileTypes: true })) {
962
+ if (!entry.isDirectory()) continue;
963
+ const projectDirectory = path.join(candidatePath, entry.name);
964
+ if (isFile(path.join(projectDirectory, "project.json")) || isFile(path.join(projectDirectory, "package.json"))) collected.push(`${candidate}/${entry.name}`);
965
+ }
966
+ }
967
+ return collected;
968
+ };
919
969
  const getWorkspacePatterns = (rootDirectory, packageJson) => {
920
970
  const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory);
921
971
  if (pnpmPatterns.length > 0) return pnpmPatterns;
922
972
  if (Array.isArray(packageJson.workspaces)) return packageJson.workspaces;
923
973
  if (packageJson.workspaces?.packages) return packageJson.workspaces.packages;
974
+ const nxPatterns = getNxWorkspaceDirectories(rootDirectory);
975
+ if (nxPatterns.length > 0) return nxPatterns;
924
976
  return [];
925
977
  };
926
978
  const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
@@ -1050,23 +1102,32 @@ const hasCompilerInConfigFile = (filePath) => {
1050
1102
  return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
1051
1103
  };
1052
1104
  const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
1105
+ const isProjectBoundary$1 = (directory) => {
1106
+ if (fs.existsSync(path.join(directory, ".git"))) return true;
1107
+ return isMonorepoRoot(directory);
1108
+ };
1053
1109
  const detectReactCompiler = (directory, packageJson) => {
1054
1110
  if (hasCompilerPackage(packageJson)) return true;
1055
1111
  if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
1056
1112
  if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
1057
1113
  if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
1058
1114
  if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
1115
+ if (isProjectBoundary$1(directory)) return false;
1059
1116
  let ancestorDirectory = path.dirname(directory);
1060
1117
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1061
1118
  const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
1062
1119
  if (isFile(ancestorPackagePath)) {
1063
1120
  if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
1064
1121
  }
1122
+ if (isProjectBoundary$1(ancestorDirectory)) return false;
1065
1123
  ancestorDirectory = path.dirname(ancestorDirectory);
1066
1124
  }
1067
1125
  return false;
1068
1126
  };
1127
+ const cachedProjectInfos = /* @__PURE__ */ new Map();
1069
1128
  const discoverProject = (directory) => {
1129
+ const cached = cachedProjectInfos.get(directory);
1130
+ if (cached !== void 0) return cached;
1070
1131
  const packageJsonPath = path.join(directory, "package.json");
1071
1132
  if (!isFile(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
1072
1133
  const packageJson = readPackageJson(packageJsonPath);
@@ -1093,17 +1154,37 @@ const discoverProject = (directory) => {
1093
1154
  const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
1094
1155
  const sourceFileCount = countSourceFiles(directory);
1095
1156
  const hasReactCompiler = detectReactCompiler(directory, packageJson);
1096
- return {
1157
+ const allDependencies = collectAllDependencies(packageJson);
1158
+ const hasTanStackQuery = Object.keys(allDependencies).some((packageName) => TANSTACK_QUERY_PACKAGES.has(packageName));
1159
+ const projectInfo = {
1097
1160
  rootDirectory: directory,
1098
1161
  projectName,
1099
1162
  reactVersion,
1100
1163
  framework,
1101
1164
  hasTypeScript,
1102
1165
  hasReactCompiler,
1166
+ hasTanStackQuery,
1103
1167
  sourceFileCount
1104
1168
  };
1169
+ cachedProjectInfos.set(directory, projectInfo);
1170
+ return projectInfo;
1105
1171
  };
1106
-
1172
+ //#endregion
1173
+ //#region src/utils/format-error-chain.ts
1174
+ const collectErrorChain = (rootError) => {
1175
+ const errorChain = [];
1176
+ const visitedErrors = /* @__PURE__ */ new Set();
1177
+ let currentError = rootError;
1178
+ while (currentError !== void 0 && !visitedErrors.has(currentError)) {
1179
+ visitedErrors.add(currentError);
1180
+ errorChain.push(currentError);
1181
+ currentError = currentError instanceof Error ? currentError.cause : void 0;
1182
+ }
1183
+ return errorChain;
1184
+ };
1185
+ const formatErrorMessage = (error) => error instanceof Error ? error.message || error.name : String(error);
1186
+ const formatErrorChain = (rootError) => collectErrorChain(rootError).map(formatErrorMessage).join(" → ");
1187
+ const getErrorChainMessages = (rootError) => collectErrorChain(rootError).map(formatErrorMessage);
1107
1188
  //#endregion
1108
1189
  //#region src/utils/framed-box.ts
1109
1190
  const createFramedLine = (plainText, renderedText = plainText) => ({
@@ -1113,10 +1194,10 @@ const createFramedLine = (plainText, renderedText = plainText) => ({
1113
1194
  const renderFramedBoxString = (framedLines) => {
1114
1195
  if (framedLines.length === 0) return "";
1115
1196
  const borderColorizer = highlighter.dim;
1116
- const outerIndent = " ".repeat(SUMMARY_BOX_OUTER_INDENT_CHARS);
1117
- const horizontalPadding = " ".repeat(SUMMARY_BOX_HORIZONTAL_PADDING_CHARS);
1197
+ const outerIndent = " ".repeat(2);
1198
+ const horizontalPadding = " ".repeat(1);
1118
1199
  const maximumLineLength = Math.max(...framedLines.map((framedLine) => framedLine.plainText.length));
1119
- const borderLine = "─".repeat(maximumLineLength + SUMMARY_BOX_HORIZONTAL_PADDING_CHARS * 2);
1200
+ const borderLine = "─".repeat(maximumLineLength + 2);
1120
1201
  const lines = [];
1121
1202
  lines.push(`${outerIndent}${borderColorizer(`┌${borderLine}┐`)}`);
1122
1203
  for (const framedLine of framedLines) {
@@ -1130,7 +1211,6 @@ const printFramedBox = (framedLines) => {
1130
1211
  const rendered = renderFramedBoxString(framedLines);
1131
1212
  if (rendered) logger.log(rendered);
1132
1213
  };
1133
-
1134
1214
  //#endregion
1135
1215
  //#region src/utils/group-by.ts
1136
1216
  const groupBy = (items, keyFn) => {
@@ -1143,11 +1223,45 @@ const groupBy = (items, keyFn) => {
1143
1223
  }
1144
1224
  return groups;
1145
1225
  };
1146
-
1147
1226
  //#endregion
1148
1227
  //#region src/utils/indent-multiline-text.ts
1149
1228
  const indentMultilineText = (text, linePrefix) => text.split("\n").map((lineText) => `${linePrefix}${lineText}`).join("\n");
1150
-
1229
+ //#endregion
1230
+ //#region src/utils/validate-config-types.ts
1231
+ const BOOLEAN_FIELD_NAMES = [
1232
+ "lint",
1233
+ "deadCode",
1234
+ "verbose",
1235
+ "customRulesOnly",
1236
+ "share",
1237
+ "respectInlineDisables"
1238
+ ];
1239
+ const warnConfigField = (message) => {
1240
+ process.stderr.write(`[react-doctor] ${message}\n`);
1241
+ };
1242
+ const coerceMaybeBooleanString = (fieldName, value) => {
1243
+ if (typeof value === "boolean" || value === void 0) return value;
1244
+ if (value === "true") {
1245
+ warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
1246
+ return true;
1247
+ }
1248
+ if (value === "false") {
1249
+ warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
1250
+ return false;
1251
+ }
1252
+ warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
1253
+ };
1254
+ const validateConfigTypes = (config) => {
1255
+ const validated = { ...config };
1256
+ for (const fieldName of BOOLEAN_FIELD_NAMES) {
1257
+ const original = config[fieldName];
1258
+ if (original === void 0) continue;
1259
+ const coerced = coerceMaybeBooleanString(fieldName, original);
1260
+ if (coerced === void 0) delete validated[fieldName];
1261
+ else validated[fieldName] = coerced;
1262
+ }
1263
+ return validated;
1264
+ };
1151
1265
  //#endregion
1152
1266
  //#region src/utils/load-config.ts
1153
1267
  const CONFIG_FILENAME = "react-doctor.config.json";
@@ -1157,33 +1271,54 @@ const loadConfigFromDirectory = (directory) => {
1157
1271
  if (isFile(configFilePath)) try {
1158
1272
  const fileContent = fs.readFileSync(configFilePath, "utf-8");
1159
1273
  const parsed = JSON.parse(fileContent);
1160
- if (isPlainObject(parsed)) return parsed;
1161
- console.warn(`Warning: ${CONFIG_FILENAME} must be a JSON object, ignoring.`);
1274
+ if (isPlainObject(parsed)) return validateConfigTypes(parsed);
1275
+ logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
1162
1276
  } catch (error) {
1163
- console.warn(`Warning: Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
1277
+ logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
1164
1278
  }
1165
1279
  const packageJsonPath = path.join(directory, "package.json");
1166
1280
  if (isFile(packageJsonPath)) try {
1167
1281
  const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
1168
- const embeddedConfig = JSON.parse(fileContent)[PACKAGE_JSON_CONFIG_KEY];
1169
- if (isPlainObject(embeddedConfig)) return embeddedConfig;
1282
+ const packageJson = JSON.parse(fileContent);
1283
+ if (isPlainObject(packageJson)) {
1284
+ const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
1285
+ if (isPlainObject(embeddedConfig)) return validateConfigTypes(embeddedConfig);
1286
+ }
1170
1287
  } catch {
1171
1288
  return null;
1172
1289
  }
1173
1290
  return null;
1174
1291
  };
1292
+ const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
1293
+ const cachedConfigs = /* @__PURE__ */ new Map();
1175
1294
  const loadConfig = (rootDirectory) => {
1295
+ const cached = cachedConfigs.get(rootDirectory);
1296
+ if (cached !== void 0) return cached;
1176
1297
  const localConfig = loadConfigFromDirectory(rootDirectory);
1177
- if (localConfig) return localConfig;
1298
+ if (localConfig) {
1299
+ cachedConfigs.set(rootDirectory, localConfig);
1300
+ return localConfig;
1301
+ }
1302
+ if (isProjectBoundary(rootDirectory)) {
1303
+ cachedConfigs.set(rootDirectory, null);
1304
+ return null;
1305
+ }
1178
1306
  let ancestorDirectory = path.dirname(rootDirectory);
1179
1307
  while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
1180
1308
  const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
1181
- if (ancestorConfig) return ancestorConfig;
1309
+ if (ancestorConfig) {
1310
+ cachedConfigs.set(rootDirectory, ancestorConfig);
1311
+ return ancestorConfig;
1312
+ }
1313
+ if (isProjectBoundary(ancestorDirectory)) {
1314
+ cachedConfigs.set(rootDirectory, null);
1315
+ return null;
1316
+ }
1182
1317
  ancestorDirectory = path.dirname(ancestorDirectory);
1183
1318
  }
1319
+ cachedConfigs.set(rootDirectory, null);
1184
1320
  return null;
1185
1321
  };
1186
-
1187
1322
  //#endregion
1188
1323
  //#region src/utils/resolve-compatible-node.ts
1189
1324
  const parseNodeVersion = (versionString) => {
@@ -1224,23 +1359,25 @@ const findCompatibleNvmBinary = () => {
1224
1359
  return existsSync(binaryPath) ? binaryPath : null;
1225
1360
  };
1226
1361
  const getNodeVersionFromBinary = (binaryPath) => {
1227
- try {
1228
- return execSync(`"${binaryPath}" --version`, { encoding: "utf-8" }).trim();
1229
- } catch {
1230
- return null;
1231
- }
1362
+ const result = spawnSync(binaryPath, ["--version"], { encoding: "utf-8" });
1363
+ if (result.error || result.status !== 0) return null;
1364
+ return result.stdout.toString().trim();
1232
1365
  };
1233
1366
  const installNodeViaNvm = () => {
1234
1367
  const nvmDirectory = getNvmDirectory();
1235
1368
  if (!nvmDirectory) return false;
1236
1369
  const nvmScript = path.join(nvmDirectory, "nvm.sh");
1237
1370
  if (!existsSync(nvmScript)) return false;
1238
- try {
1239
- execSync(`bash -c ". '${nvmScript}' && nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}"`, { stdio: "inherit" });
1240
- return findCompatibleNvmBinary() !== null;
1241
- } catch {
1242
- return false;
1243
- }
1371
+ const result = spawnSync("bash", ["-c", ". \"$NVM_SCRIPT\" && nvm install \"$NODE_MAJOR\""], {
1372
+ stdio: "inherit",
1373
+ env: {
1374
+ ...process.env,
1375
+ NVM_SCRIPT: nvmScript,
1376
+ NODE_MAJOR: String(24)
1377
+ }
1378
+ });
1379
+ if (result.error || result.status !== 0) return false;
1380
+ return findCompatibleNvmBinary() !== null;
1244
1381
  };
1245
1382
  const resolveNodeForOxlint = () => {
1246
1383
  if (isCurrentNodeCompatibleWithOxlint()) return {
@@ -1258,22 +1395,23 @@ const resolveNodeForOxlint = () => {
1258
1395
  version
1259
1396
  };
1260
1397
  };
1261
-
1262
1398
  //#endregion
1263
1399
  //#region src/utils/resolve-lint-include-paths.ts
1264
1400
  const listSourceFilesViaGit = (rootDirectory) => {
1265
1401
  const result = spawnSync("git", [
1266
1402
  "ls-files",
1403
+ "-z",
1267
1404
  "--cached",
1268
1405
  "--others",
1269
- "--exclude-standard"
1406
+ "--exclude-standard",
1407
+ "--recurse-submodules"
1270
1408
  ], {
1271
1409
  cwd: rootDirectory,
1272
1410
  encoding: "utf-8",
1273
1411
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1274
1412
  });
1275
1413
  if (result.error || result.status !== 0) return null;
1276
- return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1414
+ return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1277
1415
  };
1278
1416
  const listSourceFilesViaFilesystem = (rootDirectory) => {
1279
1417
  const filePaths = [];
@@ -1301,7 +1439,6 @@ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
1301
1439
  return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
1302
1440
  });
1303
1441
  };
1304
-
1305
1442
  //#endregion
1306
1443
  //#region src/utils/collect-unused-file-paths.ts
1307
1444
  const collectUnusedFilePaths = (filesIssues) => {
@@ -1315,40 +1452,64 @@ const collectUnusedFilePaths = (filesIssues) => {
1315
1452
  }
1316
1453
  return unusedFilePaths;
1317
1454
  };
1318
-
1455
+ //#endregion
1456
+ //#region src/utils/extract-failed-plugin-name.ts
1457
+ const PLUGIN_CONFIG_PATTERN = /(?:^|[/\\\s])([a-z][a-z0-9-]*)\.config\./i;
1458
+ const RC_DOTFILE_PATTERN = /(?:^|[/\\])\.([a-z][a-z0-9-]*?)rc(?:\.[a-z]+)?(?:\b|$)/i;
1459
+ const extractFailedPluginName = (error) => {
1460
+ for (const errorMessage of getErrorChainMessages(error)) {
1461
+ const pluginNameMatch = errorMessage.match(PLUGIN_CONFIG_PATTERN);
1462
+ if (pluginNameMatch?.[1]) return pluginNameMatch[1].toLowerCase();
1463
+ const rcMatch = errorMessage.match(RC_DOTFILE_PATTERN);
1464
+ if (rcMatch?.[1]) return rcMatch[1].toLowerCase();
1465
+ }
1466
+ return null;
1467
+ };
1468
+ //#endregion
1469
+ //#region src/utils/has-knip-config.ts
1470
+ const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
1319
1471
  //#endregion
1320
1472
  //#region src/utils/run-knip.ts
1321
- const KNIP_CATEGORY_MAP = {
1322
- files: "Dead Code",
1323
- exports: "Dead Code",
1324
- types: "Dead Code",
1325
- duplicates: "Dead Code"
1326
- };
1327
- const KNIP_MESSAGE_MAP = {
1328
- files: "Unused file",
1329
- exports: "Unused export",
1330
- types: "Unused type",
1331
- duplicates: "Duplicate export"
1332
- };
1333
- const KNIP_SEVERITY_MAP = {
1334
- files: "warning",
1335
- exports: "warning",
1336
- types: "warning",
1337
- duplicates: "warning"
1473
+ const KNIP_ISSUE_TYPE_DESCRIPTORS = {
1474
+ files: {
1475
+ category: "Dead Code",
1476
+ message: "Unused file",
1477
+ severity: "warning"
1478
+ },
1479
+ exports: {
1480
+ category: "Dead Code",
1481
+ message: "Unused export",
1482
+ severity: "warning"
1483
+ },
1484
+ types: {
1485
+ category: "Dead Code",
1486
+ message: "Unused type",
1487
+ severity: "warning"
1488
+ },
1489
+ duplicates: {
1490
+ category: "Dead Code",
1491
+ message: "Duplicate export",
1492
+ severity: "warning"
1493
+ }
1494
+ };
1495
+ const FALLBACK_KNIP_DESCRIPTOR = {
1496
+ category: "Dead Code",
1497
+ message: "Issue",
1498
+ severity: "warning"
1338
1499
  };
1339
1500
  const collectIssueRecords = (records, issueType, rootDirectory) => {
1501
+ const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS[issueType] ?? FALLBACK_KNIP_DESCRIPTOR;
1340
1502
  const diagnostics = [];
1341
1503
  for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
1342
1504
  filePath: path.relative(rootDirectory, issue.filePath),
1343
1505
  plugin: "knip",
1344
1506
  rule: issueType,
1345
- severity: KNIP_SEVERITY_MAP[issueType] ?? "warning",
1346
- message: `${KNIP_MESSAGE_MAP[issueType]}: ${issue.symbol}`,
1507
+ severity: descriptor.severity,
1508
+ message: `${descriptor.message}: ${issue.symbol}`,
1347
1509
  help: "",
1348
1510
  line: 0,
1349
1511
  column: 0,
1350
- category: KNIP_CATEGORY_MAP[issueType] ?? "Dead Code",
1351
- weight: 1
1512
+ category: descriptor.category
1352
1513
  });
1353
1514
  return diagnostics;
1354
1515
  };
@@ -1357,10 +1518,11 @@ const silenced = async (fn) => {
1357
1518
  const originalInfo = console.info;
1358
1519
  const originalWarn = console.warn;
1359
1520
  const originalError = console.error;
1360
- console.log = () => {};
1361
- console.info = () => {};
1362
- console.warn = () => {};
1363
- console.error = () => {};
1521
+ const noop = () => {};
1522
+ console.log = noop;
1523
+ console.info = noop;
1524
+ console.warn = noop;
1525
+ console.error = noop;
1364
1526
  try {
1365
1527
  return await fn();
1366
1528
  } finally {
@@ -1370,12 +1532,15 @@ const silenced = async (fn) => {
1370
1532
  console.error = originalError;
1371
1533
  }
1372
1534
  };
1373
- const CONFIG_LOADING_ERROR_PATTERN = /Error loading .*\/([a-z-]+)\.config\./;
1374
- const extractFailedPluginName = (error) => {
1375
- return String(error).match(CONFIG_LOADING_ERROR_PATTERN)?.[1] ?? null;
1535
+ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
1536
+ const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
1537
+ const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
1538
+ const failedPlugin = extractFailedPluginName(error);
1539
+ if (!failedPlugin || !(failedPlugin in parsedConfig) || disabledPlugins.has(failedPlugin)) return false;
1540
+ disabledPlugins.add(failedPlugin);
1541
+ parsedConfig[failedPlugin] = false;
1542
+ return true;
1376
1543
  };
1377
- const TSCONFIG_FILENAMES = ["tsconfig.base.json", "tsconfig.json"];
1378
- const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES.find((filename) => fs.existsSync(path.join(directory, filename)));
1379
1544
  const runKnipWithOptions = async (knipCwd, workspaceName) => {
1380
1545
  const tsConfigFile = resolveTsConfigFile(knipCwd);
1381
1546
  const options = await silenced(() => createOptions({
@@ -1385,45 +1550,48 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
1385
1550
  ...tsConfigFile ? { tsConfigFile } : {}
1386
1551
  }));
1387
1552
  const parsedConfig = options.parsedConfig;
1388
- for (let attempt = 0; attempt <= MAX_KNIP_RETRIES; attempt++) try {
1553
+ const disabledPlugins = /* @__PURE__ */ new Set();
1554
+ let lastKnipError;
1555
+ for (let attempt = 0; attempt < 6; attempt++) try {
1389
1556
  return await silenced(() => main(options));
1390
1557
  } catch (error) {
1391
- const failedPlugin = extractFailedPluginName(error);
1392
- if (!failedPlugin || attempt === MAX_KNIP_RETRIES) throw error;
1393
- parsedConfig[failedPlugin] = false;
1558
+ lastKnipError = error;
1559
+ if (!tryDisableFailedPlugin(error, parsedConfig, disabledPlugins)) throw error;
1394
1560
  }
1395
- throw new Error("Unreachable");
1561
+ throw lastKnipError;
1396
1562
  };
1397
1563
  const hasNodeModules = (directory) => {
1398
1564
  const nodeModulesPath = path.join(directory, "node_modules");
1399
1565
  return fs.existsSync(nodeModulesPath) && fs.statSync(nodeModulesPath).isDirectory();
1400
1566
  };
1567
+ const resolveWorkspaceName = (rootDirectory) => {
1568
+ const packageJsonPath = path.join(rootDirectory, "package.json");
1569
+ return (isFile(packageJsonPath) ? readPackageJson(packageJsonPath) : {}).name ?? path.basename(rootDirectory);
1570
+ };
1571
+ const runKnipForProject = async (rootDirectory, monorepoRoot) => {
1572
+ if (!monorepoRoot || hasKnipConfig(rootDirectory)) return runKnipWithOptions(rootDirectory);
1573
+ try {
1574
+ return await runKnipWithOptions(monorepoRoot, resolveWorkspaceName(rootDirectory));
1575
+ } catch {
1576
+ return runKnipWithOptions(rootDirectory);
1577
+ }
1578
+ };
1401
1579
  const runKnip = async (rootDirectory) => {
1402
1580
  const monorepoRoot = findMonorepoRoot(rootDirectory);
1403
1581
  if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
1404
- let knipResult;
1405
- if (monorepoRoot) {
1406
- const packageJsonPath = path.join(rootDirectory, "package.json");
1407
- const workspaceName = (isFile(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
1408
- try {
1409
- knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
1410
- } catch {
1411
- knipResult = await runKnipWithOptions(rootDirectory);
1412
- }
1413
- } else knipResult = await runKnipWithOptions(rootDirectory);
1414
- const { issues } = knipResult;
1582
+ const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
1415
1583
  const diagnostics = [];
1584
+ const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
1416
1585
  for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
1417
1586
  filePath: path.relative(rootDirectory, unusedFilePath),
1418
1587
  plugin: "knip",
1419
1588
  rule: "files",
1420
- severity: KNIP_SEVERITY_MAP["files"],
1421
- message: KNIP_MESSAGE_MAP["files"],
1589
+ severity: filesDescriptor.severity,
1590
+ message: filesDescriptor.message,
1422
1591
  help: "This file is not imported by any other file in the project.",
1423
1592
  line: 0,
1424
1593
  column: 0,
1425
- category: KNIP_CATEGORY_MAP["files"],
1426
- weight: 1
1594
+ category: filesDescriptor.category
1427
1595
  });
1428
1596
  for (const issueType of [
1429
1597
  "exports",
@@ -1432,7 +1600,113 @@ const runKnip = async (rootDirectory) => {
1432
1600
  ]) diagnostics.push(...collectIssueRecords(issues[issueType], issueType, rootDirectory));
1433
1601
  return diagnostics;
1434
1602
  };
1435
-
1603
+ //#endregion
1604
+ //#region src/utils/batch-include-paths.ts
1605
+ const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1606
+ const batchIncludePaths = (baseArgs, includePaths) => {
1607
+ const baseArgsLength = estimateArgsLength(baseArgs);
1608
+ const batches = [];
1609
+ let currentBatch = [];
1610
+ let currentBatchLength = baseArgsLength;
1611
+ for (const filePath of includePaths) {
1612
+ const entryLength = filePath.length + 1;
1613
+ const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > 24e3;
1614
+ const exceedsFileCount = currentBatch.length >= 500;
1615
+ if (exceedsArgLength || exceedsFileCount) {
1616
+ batches.push(currentBatch);
1617
+ currentBatch = [];
1618
+ currentBatchLength = baseArgsLength;
1619
+ }
1620
+ currentBatch.push(filePath);
1621
+ currentBatchLength += entryLength;
1622
+ }
1623
+ if (currentBatch.length > 0) batches.push(currentBatch);
1624
+ return batches;
1625
+ };
1626
+ //#endregion
1627
+ //#region src/utils/parse-gitattributes-linguist.ts
1628
+ const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
1629
+ const FALSY_VALUES = new Set([
1630
+ "false",
1631
+ "0",
1632
+ "off",
1633
+ "no"
1634
+ ]);
1635
+ const isTruthyLinguistAttribute = (token) => {
1636
+ const match = LINGUIST_ATTRIBUTE_PATTERN.exec(token);
1637
+ if (!match) return false;
1638
+ if (match[1] === void 0) return true;
1639
+ return !FALSY_VALUES.has(match[1].toLowerCase());
1640
+ };
1641
+ const parseGitattributesLinguistPaths = (filePath) => {
1642
+ let content;
1643
+ try {
1644
+ content = fs.readFileSync(filePath, "utf-8");
1645
+ } catch {
1646
+ return [];
1647
+ }
1648
+ const paths = [];
1649
+ for (const rawLine of content.split("\n")) {
1650
+ const line = rawLine.trim();
1651
+ if (line.length === 0 || line.startsWith("#")) continue;
1652
+ const tokens = line.split(/\s+/);
1653
+ if (tokens.length < 2) continue;
1654
+ const [pathSpec, ...attributes] = tokens;
1655
+ if (attributes.some(isTruthyLinguistAttribute)) paths.push(pathSpec);
1656
+ }
1657
+ return paths;
1658
+ };
1659
+ //#endregion
1660
+ //#region src/utils/read-ignore-file.ts
1661
+ const stripGitignoreEscape = (pattern) => {
1662
+ if (pattern.startsWith("\\#") || pattern.startsWith("\\!")) return pattern.slice(1);
1663
+ return pattern;
1664
+ };
1665
+ const readIgnoreFile = (filePath) => {
1666
+ let content;
1667
+ try {
1668
+ content = fs.readFileSync(filePath, "utf-8");
1669
+ } catch (error) {
1670
+ const errnoCode = error?.code;
1671
+ if (errnoCode && errnoCode !== "ENOENT") logger.warn(`Could not read ignore file ${filePath}: ${errnoCode}`);
1672
+ return [];
1673
+ }
1674
+ const patterns = [];
1675
+ for (const line of content.split("\n")) {
1676
+ const trimmed = line.trim();
1677
+ if (trimmed.length === 0) continue;
1678
+ if (trimmed.startsWith("#")) continue;
1679
+ patterns.push(stripGitignoreEscape(trimmed));
1680
+ }
1681
+ return patterns;
1682
+ };
1683
+ //#endregion
1684
+ //#region src/utils/collect-ignore-patterns.ts
1685
+ const IGNORE_FILENAMES = [
1686
+ ".eslintignore",
1687
+ ".oxlintignore",
1688
+ ".prettierignore"
1689
+ ];
1690
+ const cachedPatternsByRoot = /* @__PURE__ */ new Map();
1691
+ const computeIgnorePatterns = (rootDirectory) => {
1692
+ const seen = /* @__PURE__ */ new Set();
1693
+ const patterns = [];
1694
+ const addPattern = (pattern) => {
1695
+ if (seen.has(pattern)) return;
1696
+ seen.add(pattern);
1697
+ patterns.push(pattern);
1698
+ };
1699
+ for (const filename of IGNORE_FILENAMES) for (const pattern of readIgnoreFile(path.join(rootDirectory, filename))) addPattern(pattern);
1700
+ for (const linguistPath of parseGitattributesLinguistPaths(path.join(rootDirectory, ".gitattributes"))) addPattern(linguistPath);
1701
+ return patterns;
1702
+ };
1703
+ const collectIgnorePatterns = (rootDirectory) => {
1704
+ const cached = cachedPatternsByRoot.get(rootDirectory);
1705
+ if (cached !== void 0) return cached;
1706
+ const patterns = computeIgnorePatterns(rootDirectory);
1707
+ cachedPatternsByRoot.set(rootDirectory, patterns);
1708
+ return patterns;
1709
+ };
1436
1710
  //#endregion
1437
1711
  //#region src/oxlint-config.ts
1438
1712
  const esmRequire$1 = createRequire(import.meta.url);
@@ -1462,7 +1736,23 @@ const REACT_NATIVE_RULES = {
1462
1736
  "react-doctor/rn-no-inline-flatlist-renderitem": "warn",
1463
1737
  "react-doctor/rn-no-legacy-shadow-styles": "warn",
1464
1738
  "react-doctor/rn-prefer-reanimated": "warn",
1465
- "react-doctor/rn-no-single-element-style-array": "warn"
1739
+ "react-doctor/rn-no-single-element-style-array": "warn",
1740
+ "react-doctor/rn-prefer-pressable": "warn",
1741
+ "react-doctor/rn-prefer-expo-image": "warn",
1742
+ "react-doctor/rn-no-non-native-navigator": "warn",
1743
+ "react-doctor/rn-no-scroll-state": "error",
1744
+ "react-doctor/rn-no-scrollview-mapped-list": "warn",
1745
+ "react-doctor/rn-no-inline-object-in-list-item": "warn",
1746
+ "react-doctor/rn-animate-layout-property": "error",
1747
+ "react-doctor/rn-prefer-content-inset-adjustment": "warn",
1748
+ "react-doctor/rn-pressable-shared-value-mutation": "warn",
1749
+ "react-doctor/rn-list-data-mapped": "warn",
1750
+ "react-doctor/rn-list-callback-per-row": "warn",
1751
+ "react-doctor/rn-list-recyclable-without-types": "warn",
1752
+ "react-doctor/rn-animation-reaction-as-derived": "warn",
1753
+ "react-doctor/rn-bottom-sheet-prefer-native": "warn",
1754
+ "react-doctor/rn-scrollview-dynamic-padding": "warn",
1755
+ "react-doctor/rn-style-prefer-boxshadow": "warn"
1466
1756
  };
1467
1757
  const TANSTACK_START_RULES = {
1468
1758
  "react-doctor/tanstack-start-route-property-order": "error",
@@ -1481,22 +1771,41 @@ const TANSTACK_START_RULES = {
1481
1771
  "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
1482
1772
  };
1483
1773
  const REACT_COMPILER_RULES = {
1484
- "react-hooks-js/set-state-in-render": "error",
1485
- "react-hooks-js/immutability": "error",
1486
- "react-hooks-js/refs": "error",
1487
- "react-hooks-js/purity": "error",
1488
- "react-hooks-js/hooks": "error",
1489
- "react-hooks-js/set-state-in-effect": "error",
1490
- "react-hooks-js/globals": "error",
1491
- "react-hooks-js/error-boundaries": "error",
1492
- "react-hooks-js/preserve-manual-memoization": "error",
1493
- "react-hooks-js/unsupported-syntax": "error",
1494
- "react-hooks-js/component-hook-factories": "error",
1495
- "react-hooks-js/static-components": "error",
1496
- "react-hooks-js/use-memo": "error",
1497
- "react-hooks-js/void-use-memo": "error",
1498
- "react-hooks-js/incompatible-library": "error",
1499
- "react-hooks-js/todo": "error"
1774
+ "react-hooks-js/set-state-in-render": "warn",
1775
+ "react-hooks-js/immutability": "warn",
1776
+ "react-hooks-js/refs": "warn",
1777
+ "react-hooks-js/purity": "warn",
1778
+ "react-hooks-js/hooks": "warn",
1779
+ "react-hooks-js/set-state-in-effect": "warn",
1780
+ "react-hooks-js/globals": "warn",
1781
+ "react-hooks-js/error-boundaries": "warn",
1782
+ "react-hooks-js/preserve-manual-memoization": "warn",
1783
+ "react-hooks-js/unsupported-syntax": "warn",
1784
+ "react-hooks-js/component-hook-factories": "warn",
1785
+ "react-hooks-js/static-components": "warn",
1786
+ "react-hooks-js/use-memo": "warn",
1787
+ "react-hooks-js/void-use-memo": "warn",
1788
+ "react-hooks-js/incompatible-library": "warn",
1789
+ "react-hooks-js/todo": "warn"
1790
+ };
1791
+ const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
1792
+ if (!hasReactCompiler || customRulesOnly) return [];
1793
+ try {
1794
+ return [{
1795
+ name: "react-hooks-js",
1796
+ specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1797
+ }];
1798
+ } catch {
1799
+ return [];
1800
+ }
1801
+ };
1802
+ const TANSTACK_QUERY_RULES = {
1803
+ "react-doctor/query-stable-query-client": "warn",
1804
+ "react-doctor/query-no-rest-destructuring": "warn",
1805
+ "react-doctor/query-no-void-query-fn": "warn",
1806
+ "react-doctor/query-no-query-in-effect": "warn",
1807
+ "react-doctor/query-mutation-missing-invalidation": "warn",
1808
+ "react-doctor/query-no-usequery-for-mutation": "warn"
1500
1809
  };
1501
1810
  const BUILTIN_REACT_RULES = {
1502
1811
  "react/rules-of-hooks": "error",
@@ -1528,7 +1837,113 @@ const BUILTIN_A11Y_RULES = {
1528
1837
  "jsx-a11y/no-distracting-elements": "error",
1529
1838
  "jsx-a11y/iframe-has-title": "warn"
1530
1839
  };
1531
- const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRulesOnly = false }) => ({
1840
+ const GLOBAL_REACT_DOCTOR_RULES = {
1841
+ "react-doctor/no-derived-state-effect": "warn",
1842
+ "react-doctor/no-fetch-in-effect": "warn",
1843
+ "react-doctor/no-cascading-set-state": "warn",
1844
+ "react-doctor/no-effect-event-handler": "warn",
1845
+ "react-doctor/no-effect-event-in-deps": "error",
1846
+ "react-doctor/no-prop-callback-in-effect": "warn",
1847
+ "react-doctor/no-derived-useState": "warn",
1848
+ "react-doctor/prefer-useReducer": "warn",
1849
+ "react-doctor/rerender-lazy-state-init": "warn",
1850
+ "react-doctor/rerender-functional-setstate": "warn",
1851
+ "react-doctor/rerender-dependencies": "error",
1852
+ "react-doctor/rerender-state-only-in-handlers": "warn",
1853
+ "react-doctor/rerender-defer-reads-hook": "warn",
1854
+ "react-doctor/advanced-event-handler-refs": "warn",
1855
+ "react-doctor/no-giant-component": "warn",
1856
+ "react-doctor/no-render-in-render": "warn",
1857
+ "react-doctor/no-many-boolean-props": "warn",
1858
+ "react-doctor/no-react19-deprecated-apis": "warn",
1859
+ "react-doctor/no-render-prop-children": "warn",
1860
+ "react-doctor/no-nested-component-definition": "error",
1861
+ "react-doctor/react-compiler-destructure-method": "warn",
1862
+ "react-doctor/no-usememo-simple-expression": "warn",
1863
+ "react-doctor/no-layout-property-animation": "error",
1864
+ "react-doctor/rerender-memo-with-default-value": "warn",
1865
+ "react-doctor/rerender-memo-before-early-return": "warn",
1866
+ "react-doctor/rerender-transitions-scroll": "warn",
1867
+ "react-doctor/rerender-derived-state-from-hook": "warn",
1868
+ "react-doctor/async-defer-await": "warn",
1869
+ "react-doctor/async-await-in-loop": "warn",
1870
+ "react-doctor/rendering-animate-svg-wrapper": "warn",
1871
+ "react-doctor/rendering-hoist-jsx": "warn",
1872
+ "react-doctor/rendering-hydration-mismatch-time": "warn",
1873
+ "react-doctor/no-inline-prop-on-memo-component": "warn",
1874
+ "react-doctor/rendering-hydration-no-flicker": "warn",
1875
+ "react-doctor/rendering-script-defer-async": "warn",
1876
+ "react-doctor/rendering-usetransition-loading": "warn",
1877
+ "react-doctor/no-transition-all": "warn",
1878
+ "react-doctor/no-global-css-variable-animation": "error",
1879
+ "react-doctor/no-large-animated-blur": "warn",
1880
+ "react-doctor/no-scale-from-zero": "warn",
1881
+ "react-doctor/no-permanent-will-change": "warn",
1882
+ "react-doctor/no-eval": "error",
1883
+ "react-doctor/no-secrets-in-client-code": "warn",
1884
+ "react-doctor/no-generic-handler-names": "warn",
1885
+ "react-doctor/js-flatmap-filter": "warn",
1886
+ "react-doctor/js-combine-iterations": "warn",
1887
+ "react-doctor/js-tosorted-immutable": "warn",
1888
+ "react-doctor/js-hoist-regexp": "warn",
1889
+ "react-doctor/js-hoist-intl": "warn",
1890
+ "react-doctor/js-cache-property-access": "warn",
1891
+ "react-doctor/js-length-check-first": "warn",
1892
+ "react-doctor/js-min-max-loop": "warn",
1893
+ "react-doctor/js-set-map-lookups": "warn",
1894
+ "react-doctor/js-batch-dom-css": "warn",
1895
+ "react-doctor/js-index-maps": "warn",
1896
+ "react-doctor/js-cache-storage": "warn",
1897
+ "react-doctor/js-early-exit": "warn",
1898
+ "react-doctor/no-barrel-import": "warn",
1899
+ "react-doctor/no-dynamic-import-path": "warn",
1900
+ "react-doctor/no-full-lodash-import": "warn",
1901
+ "react-doctor/no-moment": "warn",
1902
+ "react-doctor/prefer-dynamic-import": "warn",
1903
+ "react-doctor/use-lazy-motion": "warn",
1904
+ "react-doctor/no-undeferred-third-party": "warn",
1905
+ "react-doctor/no-array-index-as-key": "warn",
1906
+ "react-doctor/no-polymorphic-children": "warn",
1907
+ "react-doctor/rendering-conditional-render": "warn",
1908
+ "react-doctor/rendering-svg-precision": "warn",
1909
+ "react-doctor/no-prevent-default": "warn",
1910
+ "react-doctor/no-document-start-view-transition": "warn",
1911
+ "react-doctor/no-flush-sync": "warn",
1912
+ "react-doctor/server-auth-actions": "error",
1913
+ "react-doctor/server-after-nonblocking": "warn",
1914
+ "react-doctor/server-no-mutable-module-state": "error",
1915
+ "react-doctor/server-cache-with-object-literal": "warn",
1916
+ "react-doctor/server-hoist-static-io": "warn",
1917
+ "react-doctor/server-dedup-props": "warn",
1918
+ "react-doctor/server-sequential-independent-await": "warn",
1919
+ "react-doctor/server-fetch-without-revalidate": "warn",
1920
+ "react-doctor/client-passive-event-listeners": "warn",
1921
+ "react-doctor/client-localstorage-no-version": "warn",
1922
+ "react-doctor/no-inline-bounce-easing": "warn",
1923
+ "react-doctor/no-z-index-9999": "warn",
1924
+ "react-doctor/no-inline-exhaustive-style": "warn",
1925
+ "react-doctor/no-side-tab-border": "warn",
1926
+ "react-doctor/no-pure-black-background": "warn",
1927
+ "react-doctor/no-gradient-text": "warn",
1928
+ "react-doctor/no-dark-mode-glow": "warn",
1929
+ "react-doctor/no-justified-text": "warn",
1930
+ "react-doctor/no-tiny-text": "warn",
1931
+ "react-doctor/no-wide-letter-spacing": "warn",
1932
+ "react-doctor/no-gray-on-colored-background": "warn",
1933
+ "react-doctor/no-layout-transition-inline": "warn",
1934
+ "react-doctor/no-disabled-zoom": "error",
1935
+ "react-doctor/no-outline-none": "warn",
1936
+ "react-doctor/no-long-transition-duration": "warn",
1937
+ "react-doctor/async-parallel": "warn"
1938
+ };
1939
+ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
1940
+ ...Object.keys(GLOBAL_REACT_DOCTOR_RULES),
1941
+ ...Object.keys(NEXTJS_RULES),
1942
+ ...Object.keys(REACT_NATIVE_RULES),
1943
+ ...Object.keys(TANSTACK_START_RULES),
1944
+ ...Object.keys(TANSTACK_QUERY_RULES)
1945
+ ]);
1946
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false }) => ({
1532
1947
  categories: {
1533
1948
  correctness: "off",
1534
1949
  suspicious: "off",
@@ -1538,88 +1953,23 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRul
1538
1953
  style: "off",
1539
1954
  nursery: "off"
1540
1955
  },
1541
- plugins: [
1542
- "react",
1543
- "jsx-a11y",
1544
- ...hasReactCompiler ? [] : ["react-perf"]
1545
- ],
1546
- jsPlugins: [...hasReactCompiler && !customRulesOnly ? [{
1547
- name: "react-hooks-js",
1548
- specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
1549
- }] : [], pluginPath],
1956
+ plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
1957
+ jsPlugins: [...resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly), pluginPath],
1550
1958
  rules: {
1551
1959
  ...customRulesOnly ? {} : BUILTIN_REACT_RULES,
1552
1960
  ...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
1553
1961
  ...hasReactCompiler && !customRulesOnly ? REACT_COMPILER_RULES : {},
1554
- "react-doctor/no-derived-state-effect": "error",
1555
- "react-doctor/no-fetch-in-effect": "error",
1556
- "react-doctor/no-cascading-set-state": "warn",
1557
- "react-doctor/no-effect-event-handler": "warn",
1558
- "react-doctor/no-derived-useState": "warn",
1559
- "react-doctor/prefer-useReducer": "warn",
1560
- "react-doctor/rerender-lazy-state-init": "warn",
1561
- "react-doctor/rerender-functional-setstate": "warn",
1562
- "react-doctor/rerender-dependencies": "error",
1563
- "react-doctor/no-giant-component": "warn",
1564
- "react-doctor/no-render-in-render": "warn",
1565
- "react-doctor/no-nested-component-definition": "error",
1566
- "react-doctor/no-usememo-simple-expression": "warn",
1567
- "react-doctor/no-layout-property-animation": "error",
1568
- "react-doctor/rerender-memo-with-default-value": "warn",
1569
- "react-doctor/rendering-animate-svg-wrapper": "warn",
1570
- "react-doctor/no-inline-prop-on-memo-component": "warn",
1571
- "react-doctor/rendering-hydration-no-flicker": "warn",
1572
- "react-doctor/rendering-script-defer-async": "warn",
1573
- "react-doctor/no-transition-all": "warn",
1574
- "react-doctor/no-global-css-variable-animation": "error",
1575
- "react-doctor/no-large-animated-blur": "warn",
1576
- "react-doctor/no-scale-from-zero": "warn",
1577
- "react-doctor/no-permanent-will-change": "warn",
1578
- "react-doctor/no-secrets-in-client-code": "error",
1579
- "react-doctor/js-flatmap-filter": "warn",
1580
- "react-doctor/no-barrel-import": "warn",
1581
- "react-doctor/no-full-lodash-import": "warn",
1582
- "react-doctor/no-moment": "warn",
1583
- "react-doctor/prefer-dynamic-import": "warn",
1584
- "react-doctor/use-lazy-motion": "warn",
1585
- "react-doctor/no-undeferred-third-party": "warn",
1586
- "react-doctor/no-array-index-as-key": "warn",
1587
- "react-doctor/rendering-conditional-render": "warn",
1588
- "react-doctor/no-prevent-default": "warn",
1589
- "react-doctor/server-auth-actions": "error",
1590
- "react-doctor/server-after-nonblocking": "warn",
1591
- "react-doctor/client-passive-event-listeners": "warn",
1592
- "react-doctor/query-stable-query-client": "error",
1593
- "react-doctor/query-no-rest-destructuring": "warn",
1594
- "react-doctor/query-no-void-query-fn": "warn",
1595
- "react-doctor/query-no-query-in-effect": "warn",
1596
- "react-doctor/query-mutation-missing-invalidation": "warn",
1597
- "react-doctor/query-no-usequery-for-mutation": "warn",
1598
- "react-doctor/no-inline-bounce-easing": "warn",
1599
- "react-doctor/no-z-index-9999": "warn",
1600
- "react-doctor/no-inline-exhaustive-style": "warn",
1601
- "react-doctor/no-side-tab-border": "warn",
1602
- "react-doctor/no-pure-black-background": "warn",
1603
- "react-doctor/no-gradient-text": "warn",
1604
- "react-doctor/no-dark-mode-glow": "warn",
1605
- "react-doctor/no-justified-text": "warn",
1606
- "react-doctor/no-tiny-text": "warn",
1607
- "react-doctor/no-wide-letter-spacing": "warn",
1608
- "react-doctor/no-gray-on-colored-background": "warn",
1609
- "react-doctor/no-layout-transition-inline": "warn",
1610
- "react-doctor/no-disabled-zoom": "error",
1611
- "react-doctor/no-outline-none": "warn",
1612
- "react-doctor/no-long-transition-duration": "warn",
1613
- "react-doctor/async-parallel": "warn",
1962
+ ...GLOBAL_REACT_DOCTOR_RULES,
1614
1963
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1615
1964
  ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1616
- ...framework === "tanstack-start" ? TANSTACK_START_RULES : {}
1965
+ ...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
1966
+ ...hasTanStackQuery ? TANSTACK_QUERY_RULES : {}
1617
1967
  }
1618
1968
  });
1619
-
1620
1969
  //#endregion
1621
1970
  //#region src/utils/neutralize-disable-directives.ts
1622
- const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1971
+ const DISABLE_DIRECTIVE_PATTERN = /(eslint|oxlint)-disable/;
1972
+ const findFilesWithDisableDirectivesViaGit = (rootDirectory, includePaths) => {
1623
1973
  const grepArgs = [
1624
1974
  "grep",
1625
1975
  "-l",
@@ -1633,14 +1983,65 @@ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => {
1633
1983
  encoding: "utf-8",
1634
1984
  maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
1635
1985
  });
1636
- if (result.error || result.status === null) return [];
1637
- if (result.status !== 0 && result.stdout.trim().length === 0) return [];
1986
+ if (result.error || result.status === null) return null;
1987
+ if (result.status === 128) return null;
1638
1988
  return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
1639
1989
  };
1990
+ const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
1991
+ const matches = [];
1992
+ const checkFile = (relativePath) => {
1993
+ if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
1994
+ const absolutePath = path.join(rootDirectory, relativePath);
1995
+ let content;
1996
+ try {
1997
+ content = fs.readFileSync(absolutePath, "utf-8");
1998
+ } catch {
1999
+ return;
2000
+ }
2001
+ if (DISABLE_DIRECTIVE_PATTERN.test(content)) matches.push(relativePath);
2002
+ };
2003
+ if (includePaths && includePaths.length > 0) {
2004
+ for (const candidate of includePaths) checkFile(candidate);
2005
+ return matches;
2006
+ }
2007
+ const stack = [rootDirectory];
2008
+ while (stack.length > 0) {
2009
+ const current = stack.pop();
2010
+ if (current === void 0) continue;
2011
+ let entries;
2012
+ try {
2013
+ entries = fs.readdirSync(current, { withFileTypes: true });
2014
+ } catch {
2015
+ continue;
2016
+ }
2017
+ for (const entry of entries) {
2018
+ if (entry.isDirectory()) {
2019
+ if (entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
2020
+ stack.push(path.join(current, entry.name));
2021
+ continue;
2022
+ }
2023
+ if (!entry.isFile()) continue;
2024
+ const absolute = path.join(current, entry.name);
2025
+ checkFile(path.relative(rootDirectory, absolute));
2026
+ }
2027
+ }
2028
+ return matches;
2029
+ };
2030
+ const findFilesWithDisableDirectives = (rootDirectory, includePaths) => findFilesWithDisableDirectivesViaGit(rootDirectory, includePaths) ?? findFilesWithDisableDirectivesViaFilesystem(rootDirectory, includePaths);
1640
2031
  const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
1641
2032
  const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1642
2033
  const filePaths = findFilesWithDisableDirectives(rootDirectory, includePaths);
1643
2034
  const originalContents = /* @__PURE__ */ new Map();
2035
+ let isRestored = false;
2036
+ const restore = () => {
2037
+ if (isRestored) return;
2038
+ isRestored = true;
2039
+ for (const [absolutePath, originalContent] of originalContents) try {
2040
+ fs.writeFileSync(absolutePath, originalContent);
2041
+ } catch {}
2042
+ };
2043
+ const onExit = () => restore();
2044
+ process.once("exit", onExit);
1644
2045
  for (const relativePath of filePaths) {
1645
2046
  const absolutePath = path.join(rootDirectory, relativePath);
1646
2047
  let originalContent;
@@ -1656,10 +2057,10 @@ const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
1656
2057
  }
1657
2058
  }
1658
2059
  return () => {
1659
- for (const [absolutePath, originalContent] of originalContents) fs.writeFileSync(absolutePath, originalContent);
2060
+ restore();
2061
+ process.removeListener("exit", onExit);
1660
2062
  };
1661
2063
  };
1662
-
1663
2064
  //#endregion
1664
2065
  //#region src/utils/run-oxlint.ts
1665
2066
  const esmRequire = createRequire(import.meta.url);
@@ -1667,30 +2068,48 @@ const PLUGIN_CATEGORY_MAP = {
1667
2068
  react: "Correctness",
1668
2069
  "react-hooks": "Correctness",
1669
2070
  "react-hooks-js": "React Compiler",
1670
- "react-perf": "Performance",
1671
- "jsx-a11y": "Accessibility"
2071
+ "react-doctor": "Other",
2072
+ "jsx-a11y": "Accessibility",
2073
+ knip: "Dead Code"
1672
2074
  };
1673
2075
  const RULE_CATEGORY_MAP = {
1674
2076
  "react-doctor/no-derived-state-effect": "State & Effects",
1675
2077
  "react-doctor/no-fetch-in-effect": "State & Effects",
1676
2078
  "react-doctor/no-cascading-set-state": "State & Effects",
1677
2079
  "react-doctor/no-effect-event-handler": "State & Effects",
2080
+ "react-doctor/no-effect-event-in-deps": "State & Effects",
2081
+ "react-doctor/no-prop-callback-in-effect": "State & Effects",
1678
2082
  "react-doctor/no-derived-useState": "State & Effects",
1679
2083
  "react-doctor/prefer-useReducer": "State & Effects",
1680
2084
  "react-doctor/rerender-lazy-state-init": "Performance",
1681
2085
  "react-doctor/rerender-functional-setstate": "Performance",
1682
2086
  "react-doctor/rerender-dependencies": "State & Effects",
2087
+ "react-doctor/rerender-state-only-in-handlers": "Performance",
2088
+ "react-doctor/rerender-defer-reads-hook": "Performance",
2089
+ "react-doctor/advanced-event-handler-refs": "Performance",
1683
2090
  "react-doctor/no-generic-handler-names": "Architecture",
1684
2091
  "react-doctor/no-giant-component": "Architecture",
2092
+ "react-doctor/no-many-boolean-props": "Architecture",
2093
+ "react-doctor/no-react19-deprecated-apis": "Architecture",
2094
+ "react-doctor/no-render-prop-children": "Architecture",
1685
2095
  "react-doctor/no-render-in-render": "Architecture",
1686
2096
  "react-doctor/no-nested-component-definition": "Correctness",
2097
+ "react-doctor/react-compiler-destructure-method": "Architecture",
1687
2098
  "react-doctor/no-usememo-simple-expression": "Performance",
1688
2099
  "react-doctor/no-layout-property-animation": "Performance",
1689
2100
  "react-doctor/rerender-memo-with-default-value": "Performance",
2101
+ "react-doctor/rerender-memo-before-early-return": "Performance",
2102
+ "react-doctor/rerender-transitions-scroll": "Performance",
2103
+ "react-doctor/rerender-derived-state-from-hook": "Performance",
2104
+ "react-doctor/async-defer-await": "Performance",
2105
+ "react-doctor/async-await-in-loop": "Performance",
1690
2106
  "react-doctor/rendering-animate-svg-wrapper": "Performance",
2107
+ "react-doctor/rendering-hoist-jsx": "Performance",
2108
+ "react-doctor/rendering-hydration-mismatch-time": "Correctness",
1691
2109
  "react-doctor/rendering-usetransition-loading": "Performance",
1692
2110
  "react-doctor/rendering-hydration-no-flicker": "Performance",
1693
2111
  "react-doctor/rendering-script-defer-async": "Performance",
2112
+ "react-doctor/no-inline-prop-on-memo-component": "Performance",
1694
2113
  "react-doctor/no-transition-all": "Performance",
1695
2114
  "react-doctor/no-global-css-variable-animation": "Performance",
1696
2115
  "react-doctor/no-large-animated-blur": "Performance",
@@ -1698,14 +2117,19 @@ const RULE_CATEGORY_MAP = {
1698
2117
  "react-doctor/no-permanent-will-change": "Performance",
1699
2118
  "react-doctor/no-secrets-in-client-code": "Security",
1700
2119
  "react-doctor/no-barrel-import": "Bundle Size",
2120
+ "react-doctor/no-dynamic-import-path": "Bundle Size",
1701
2121
  "react-doctor/no-full-lodash-import": "Bundle Size",
1702
2122
  "react-doctor/no-moment": "Bundle Size",
1703
2123
  "react-doctor/prefer-dynamic-import": "Bundle Size",
1704
2124
  "react-doctor/use-lazy-motion": "Bundle Size",
1705
2125
  "react-doctor/no-undeferred-third-party": "Bundle Size",
1706
2126
  "react-doctor/no-array-index-as-key": "Correctness",
2127
+ "react-doctor/no-polymorphic-children": "Architecture",
1707
2128
  "react-doctor/rendering-conditional-render": "Correctness",
2129
+ "react-doctor/rendering-svg-precision": "Performance",
1708
2130
  "react-doctor/no-prevent-default": "Correctness",
2131
+ "react-doctor/no-document-start-view-transition": "Correctness",
2132
+ "react-doctor/no-flush-sync": "Performance",
1709
2133
  "react-doctor/nextjs-no-img-element": "Next.js",
1710
2134
  "react-doctor/nextjs-async-client-component": "Next.js",
1711
2135
  "react-doctor/nextjs-no-a-element": "Next.js",
@@ -1724,7 +2148,14 @@ const RULE_CATEGORY_MAP = {
1724
2148
  "react-doctor/nextjs-no-side-effect-in-get-handler": "Security",
1725
2149
  "react-doctor/server-auth-actions": "Server",
1726
2150
  "react-doctor/server-after-nonblocking": "Server",
2151
+ "react-doctor/server-no-mutable-module-state": "Server",
2152
+ "react-doctor/server-cache-with-object-literal": "Server",
2153
+ "react-doctor/server-hoist-static-io": "Server",
2154
+ "react-doctor/server-dedup-props": "Server",
2155
+ "react-doctor/server-sequential-independent-await": "Server",
2156
+ "react-doctor/server-fetch-without-revalidate": "Server",
1727
2157
  "react-doctor/client-passive-event-listeners": "Performance",
2158
+ "react-doctor/client-localstorage-no-version": "Correctness",
1728
2159
  "react-doctor/query-stable-query-client": "TanStack Query",
1729
2160
  "react-doctor/query-no-rest-destructuring": "TanStack Query",
1730
2161
  "react-doctor/query-no-void-query-fn": "TanStack Query",
@@ -1747,6 +2178,19 @@ const RULE_CATEGORY_MAP = {
1747
2178
  "react-doctor/no-outline-none": "Accessibility",
1748
2179
  "react-doctor/no-long-transition-duration": "Performance",
1749
2180
  "react-doctor/js-flatmap-filter": "Performance",
2181
+ "react-doctor/js-combine-iterations": "Performance",
2182
+ "react-doctor/js-tosorted-immutable": "Performance",
2183
+ "react-doctor/js-hoist-regexp": "Performance",
2184
+ "react-doctor/js-hoist-intl": "Performance",
2185
+ "react-doctor/js-cache-property-access": "Performance",
2186
+ "react-doctor/js-length-check-first": "Performance",
2187
+ "react-doctor/js-min-max-loop": "Performance",
2188
+ "react-doctor/js-set-map-lookups": "Performance",
2189
+ "react-doctor/js-batch-dom-css": "Performance",
2190
+ "react-doctor/js-index-maps": "Performance",
2191
+ "react-doctor/js-cache-storage": "Performance",
2192
+ "react-doctor/js-early-exit": "Performance",
2193
+ "react-doctor/no-eval": "Security",
1750
2194
  "react-doctor/async-parallel": "Performance",
1751
2195
  "react-doctor/rn-no-raw-text": "React Native",
1752
2196
  "react-doctor/rn-no-deprecated-modules": "React Native",
@@ -1756,6 +2200,22 @@ const RULE_CATEGORY_MAP = {
1756
2200
  "react-doctor/rn-no-legacy-shadow-styles": "React Native",
1757
2201
  "react-doctor/rn-prefer-reanimated": "React Native",
1758
2202
  "react-doctor/rn-no-single-element-style-array": "React Native",
2203
+ "react-doctor/rn-prefer-pressable": "React Native",
2204
+ "react-doctor/rn-prefer-expo-image": "React Native",
2205
+ "react-doctor/rn-no-non-native-navigator": "React Native",
2206
+ "react-doctor/rn-no-scroll-state": "React Native",
2207
+ "react-doctor/rn-no-scrollview-mapped-list": "React Native",
2208
+ "react-doctor/rn-no-inline-object-in-list-item": "React Native",
2209
+ "react-doctor/rn-animate-layout-property": "React Native",
2210
+ "react-doctor/rn-prefer-content-inset-adjustment": "React Native",
2211
+ "react-doctor/rn-pressable-shared-value-mutation": "React Native",
2212
+ "react-doctor/rn-list-data-mapped": "React Native",
2213
+ "react-doctor/rn-list-callback-per-row": "React Native",
2214
+ "react-doctor/rn-list-recyclable-without-types": "React Native",
2215
+ "react-doctor/rn-animation-reaction-as-derived": "React Native",
2216
+ "react-doctor/rn-bottom-sheet-prefer-native": "React Native",
2217
+ "react-doctor/rn-scrollview-dynamic-padding": "React Native",
2218
+ "react-doctor/rn-style-prefer-boxshadow": "React Native",
1759
2219
  "react-doctor/tanstack-start-route-property-order": "TanStack Start",
1760
2220
  "react-doctor/tanstack-start-no-direct-fetch-in-loader": "TanStack Start",
1761
2221
  "react-doctor/tanstack-start-server-fn-validate-input": "TanStack Start",
@@ -1781,17 +2241,44 @@ const RULE_HELP_MAP = {
1781
2241
  "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
1782
2242
  "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
1783
2243
  "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
2244
+ "no-effect-event-in-deps": "Call the useEffectEvent callback inside the effect body without listing it; its identity is intentionally unstable",
2245
+ "no-prop-callback-in-effect": "Lift the shared state into a Provider so both sides read the same source — no useEffect-driven sync needed",
1784
2246
  "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
1785
2247
  "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
2248
+ "no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
2249
+ "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.",
2250
+ "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",
1786
2251
  "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
1787
2252
  "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
1788
2253
  "no-usememo-simple-expression": "Remove useMemo — property access, math, and ternaries are already cheap without memoization",
1789
2254
  "no-layout-property-animation": "Use `transform: translateX()` or `scale()` instead — they run on the compositor and skip layout/paint",
1790
2255
  "rerender-memo-with-default-value": "Move to module scope: `const EMPTY_ITEMS: Item[] = []` then use as the default value",
1791
2256
  "rendering-animate-svg-wrapper": "Wrap the SVG: `<motion.div animate={...}><svg>...</svg></motion.div>`",
2257
+ "rendering-hoist-jsx": "Move the static JSX to module scope: `const ICON = <svg>...</svg>` outside the component so it isn't recreated each render",
2258
+ "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",
2259
+ "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",
2260
+ "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",
2261
+ "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",
2262
+ "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",
2263
+ "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",
2264
+ "async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
2265
+ "async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
2266
+ "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",
2267
+ "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",
2268
+ "server-sequential-independent-await": "Wrap independent awaits in `Promise.all([...])` so they race instead of waterfalling — second call doesn't depend on the first",
2269
+ "server-fetch-without-revalidate": "Pass `{ next: { revalidate: <seconds> } }` (or `cache: \"no-store\"` / `next: { tags: [...] }`) so stale cached data doesn't silently persist",
2270
+ "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",
2271
+ "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",
2272
+ "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",
2273
+ "rendering-hydration-mismatch-time": "Wrap dynamic time/random values in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional",
2274
+ "no-polymorphic-children": "Expose explicit subcomponents (`<Button.Text>`, `<Button.Icon>`) so consumers don't need to switch on `typeof children`",
2275
+ "rendering-svg-precision": "Truncate path/points/transform decimals to 1–2 digits — sub-pixel precision adds bytes with no visible difference",
2276
+ "no-document-start-view-transition": "Render a <ViewTransition> component and update inside startTransition / useDeferredValue — React calls startViewTransition for you",
2277
+ "no-flush-sync": "Use startTransition for non-urgent updates — flushSync forces a sync flush that skips View Transitions and concurrent rendering",
1792
2278
  "rendering-usetransition-loading": "Replace with `const [isPending, startTransition] = useTransition()` — avoids a re-render for the loading state",
1793
2279
  "rendering-hydration-no-flicker": "Use `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)` or add `suppressHydrationWarning` to the element",
1794
2280
  "rendering-script-defer-async": "Add `defer` for DOM-dependent scripts or `async` for independent ones (analytics). In Next.js, use `<Script strategy=\"afterInteractive\" />` instead",
2281
+ "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",
1795
2282
  "no-transition-all": "List specific properties: `transition: \"opacity 200ms, transform 200ms\"` — or in Tailwind use `transition-colors`, `transition-opacity`, or `transition-transform`",
1796
2283
  "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",
1797
2284
  "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",
@@ -1799,6 +2286,7 @@ const RULE_HELP_MAP = {
1799
2286
  "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",
1800
2287
  "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)",
1801
2288
  "no-barrel-import": "Import from the direct path: `import { Button } from './components/Button'` instead of `./components`",
2289
+ "no-dynamic-import-path": "Use a string-literal path: `import('./feature/heavy.js')` so the bundler can split this chunk",
1802
2290
  "no-full-lodash-import": "Import the specific function: `import debounce from 'lodash/debounce'` — saves ~70kb",
1803
2291
  "no-moment": "Replace with `import { format } from 'date-fns'` (tree-shakeable) or `import dayjs from 'dayjs'` (2kb)",
1804
2292
  "prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
@@ -1840,7 +2328,11 @@ const RULE_HELP_MAP = {
1840
2328
  "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",
1841
2329
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
1842
2330
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
1843
- "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
2331
+ "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.",
2332
+ "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",
2333
+ "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",
2334
+ "server-dedup-props": "Pass the source array once and derive the projection on the client — passing both doubles RSC serialization bytes",
2335
+ "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.",
1844
2336
  "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",
1845
2337
  "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",
1846
2338
  "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",
@@ -1848,6 +2340,19 @@ const RULE_HELP_MAP = {
1848
2340
  "query-mutation-missing-invalidation": "Add `onSuccess: () => queryClient.invalidateQueries({ queryKey: ['...'] })` so cached data stays in sync after the mutation",
1849
2341
  "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",
1850
2342
  "js-flatmap-filter": "Use `.flatMap(item => condition ? [value] : [])` — transforms and filters in a single pass instead of creating an intermediate array",
2343
+ "js-hoist-intl": "Hoist `new Intl.NumberFormat(...)` to module scope or wrap in `useMemo` — Intl constructors allocate dozens of objects per locale lookup",
2344
+ "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`",
2345
+ "js-length-check-first": "Short-circuit with `a.length === b.length && a.every((x, i) => x === b[i])` — unequal-length arrays exit immediately",
2346
+ "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",
2347
+ "js-tosorted-immutable": "Use `array.toSorted()` (ES2023) instead of `[...array].sort()` for immutable sorting without the spread allocation",
2348
+ "js-hoist-regexp": "Hoist `new RegExp(...)` (or large regex literals) to a module-level constant so it isn't recompiled on every loop iteration",
2349
+ "js-min-max-loop": "Use `Math.min(...array)` / `Math.max(...array)` instead of sorting just to read the first or last element",
2350
+ "js-set-map-lookups": "Use a `Set` or `Map` for repeated membership tests / keyed lookups — `Array.includes`/`find` is O(n) per call",
2351
+ "js-batch-dom-css": "Batch DOM/CSS reads and writes — interleaving them inside a loop causes layout thrashing. Read first, then write",
2352
+ "js-index-maps": "Build an index `Map` once outside the loop instead of `array.find(...)` inside it",
2353
+ "js-cache-storage": "Cache repeated `localStorage`/`sessionStorage` reads in a local variable — each access serializes/deserializes",
2354
+ "js-early-exit": "Add an early `return` / `continue` to flatten deep nesting and short-circuit when the predicate is already known",
2355
+ "no-eval": "Use `JSON.parse` for serialized data, `Function(...)` (still careful) for trusted templates, or refactor to avoid dynamic code execution",
1851
2356
  "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1852
2357
  "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1853
2358
  "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
@@ -1857,6 +2362,19 @@ const RULE_HELP_MAP = {
1857
2362
  "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1858
2363
  "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1859
2364
  "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation",
2365
+ "rn-prefer-pressable": "Use `<Pressable>` from react-native (or react-native-gesture-handler) instead of legacy Touchable* components",
2366
+ "rn-prefer-expo-image": "Use `<Image>` from `expo-image` instead of `react-native` — same prop API, plus disk + memory caching, placeholders, and crossfades",
2367
+ "rn-no-non-native-navigator": "Use `@react-navigation/native-stack` (or `native-tabs` in v7+) for platform-native transitions and gestures",
2368
+ "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",
2369
+ "rn-no-scrollview-mapped-list": "Use FlashList, LegendList, or FlatList — `<ScrollView>{items.map(...)}</ScrollView>` mounts every row in memory",
2370
+ "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",
2371
+ "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",
2372
+ "rn-prefer-content-inset-adjustment": "Drop the SafeAreaView wrapper and set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView for native safe-area handling",
2373
+ "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",
2374
+ "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",
2375
+ "rn-animation-reaction-as-derived": "Replace useAnimatedReaction with `useDerivedValue(() => ..., [deps])` — shorter, native dependency tracking, no side-effect implication",
2376
+ "rn-bottom-sheet-prefer-native": "Use `<Modal presentationStyle=\"formSheet\">` (RN v7+) for native gesture handling and snap points",
2377
+ "rn-scrollview-dynamic-padding": "Use `contentInset={{ bottom: dynamicValue }}` — the OS applies it as an offset without reflowing the scroll content",
1860
2378
  "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",
1861
2379
  "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",
1862
2380
  "tanstack-start-server-fn-validate-input": "Add `.inputValidator(schema)` before `.handler()` — data crosses a network boundary and must be validated at runtime",
@@ -1911,35 +2429,61 @@ const resolvePluginPath = () => {
1911
2429
  const resolveDiagnosticCategory = (plugin, rule) => {
1912
2430
  return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
1913
2431
  };
1914
- const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
1915
- const batchIncludePaths = (baseArgs, includePaths) => {
1916
- const baseArgsLength = estimateArgsLength(baseArgs);
1917
- const batches = [];
1918
- let currentBatch = [];
1919
- let currentBatchLength = baseArgsLength;
1920
- for (const filePath of includePaths) {
1921
- const entryLength = filePath.length + 1;
1922
- const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > SPAWN_ARGS_MAX_LENGTH_CHARS;
1923
- const exceedsFileCount = currentBatch.length >= OXLINT_MAX_FILES_PER_BATCH;
1924
- if (exceedsArgLength || exceedsFileCount) {
1925
- batches.push(currentBatch);
1926
- currentBatch = [];
1927
- currentBatchLength = baseArgsLength;
1928
- }
1929
- currentBatch.push(filePath);
1930
- currentBatchLength += entryLength;
2432
+ const SANITIZED_ENV = (() => {
2433
+ const sanitized = {};
2434
+ for (const [name, value] of Object.entries(process.env)) {
2435
+ if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
2436
+ if (name.startsWith("npm_config_")) continue;
2437
+ sanitized[name] = value;
1931
2438
  }
1932
- if (currentBatch.length > 0) batches.push(currentBatch);
1933
- return batches;
1934
- };
2439
+ return sanitized;
2440
+ })();
2441
+ const OXLINT_SPAWN_TIMEOUT_MS = 5 * 6e4;
1935
2442
  const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
1936
- const child = spawn(nodeBinaryPath, args, { cwd: rootDirectory });
2443
+ const child = spawn(nodeBinaryPath, args, {
2444
+ cwd: rootDirectory,
2445
+ env: SANITIZED_ENV
2446
+ });
2447
+ const timeoutHandle = setTimeout(() => {
2448
+ child.kill("SIGKILL");
2449
+ reject(/* @__PURE__ */ new Error(`oxlint did not return within ${OXLINT_SPAWN_TIMEOUT_MS / 1e3}s — please report`));
2450
+ }, OXLINT_SPAWN_TIMEOUT_MS);
2451
+ timeoutHandle.unref?.();
1937
2452
  const stdoutBuffers = [];
1938
2453
  const stderrBuffers = [];
1939
- child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
1940
- child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
1941
- child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
1942
- child.on("close", (code, signal) => {
2454
+ let stdoutByteCount = 0;
2455
+ let stderrByteCount = 0;
2456
+ let didKillForSize = false;
2457
+ const killIfTooLarge = (incomingBytes, isStdout) => {
2458
+ if (isStdout) stdoutByteCount += incomingBytes;
2459
+ else stderrByteCount += incomingBytes;
2460
+ if (stdoutByteCount + stderrByteCount > 52428800 && !didKillForSize) {
2461
+ didKillForSize = true;
2462
+ child.kill("SIGKILL");
2463
+ return true;
2464
+ }
2465
+ return false;
2466
+ };
2467
+ child.stdout.on("data", (buffer) => {
2468
+ if (didKillForSize) return;
2469
+ stdoutBuffers.push(buffer);
2470
+ killIfTooLarge(buffer.length, true);
2471
+ });
2472
+ child.stderr.on("data", (buffer) => {
2473
+ if (didKillForSize) return;
2474
+ stderrBuffers.push(buffer);
2475
+ killIfTooLarge(buffer.length, false);
2476
+ });
2477
+ child.on("error", (error) => {
2478
+ clearTimeout(timeoutHandle);
2479
+ reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`));
2480
+ });
2481
+ child.on("close", (_code, signal) => {
2482
+ clearTimeout(timeoutHandle);
2483
+ if (didKillForSize) {
2484
+ reject(/* @__PURE__ */ new Error(`oxlint output exceeded ${PROXY_OUTPUT_MAX_BYTES} bytes — scan a smaller subset with --diff or --staged`));
2485
+ return;
2486
+ }
1943
2487
  if (signal) {
1944
2488
  const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
1945
2489
  const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
@@ -1958,15 +2502,23 @@ const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolv
1958
2502
  resolve(output);
1959
2503
  });
1960
2504
  });
2505
+ const isOxlintOutput = (value) => {
2506
+ if (typeof value !== "object" || value === null) return false;
2507
+ const candidate = value;
2508
+ return Array.isArray(candidate.diagnostics);
2509
+ };
1961
2510
  const parseOxlintOutput = (stdout) => {
1962
2511
  if (!stdout) return [];
1963
- let output;
2512
+ const jsonStart = stdout.indexOf("{");
2513
+ const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
2514
+ let parsed;
1964
2515
  try {
1965
- output = JSON.parse(stdout);
2516
+ parsed = JSON.parse(sanitizedStdout);
1966
2517
  } catch {
1967
- throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
2518
+ throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
1968
2519
  }
1969
- return output.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
2520
+ if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
2521
+ return parsed.diagnostics.filter((diagnostic) => diagnostic.code && JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
1970
2522
  const { plugin, rule } = parseRuleCode(diagnostic.code);
1971
2523
  const primaryLabel = diagnostic.labels[0];
1972
2524
  const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
@@ -1983,18 +2535,48 @@ const parseOxlintOutput = (stdout) => {
1983
2535
  };
1984
2536
  });
1985
2537
  };
1986
- const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false) => {
2538
+ const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
2539
+ const resolveTsConfigRelativePath = (rootDirectory) => {
2540
+ for (const filename of TSCONFIG_FILENAMES) if (fs.existsSync(path.join(rootDirectory, filename))) return `./${filename}`;
2541
+ return null;
2542
+ };
2543
+ let didValidateRuleRegistration = false;
2544
+ const validateRuleRegistration = () => {
2545
+ if (didValidateRuleRegistration) return;
2546
+ didValidateRuleRegistration = true;
2547
+ const missingHelp = [];
2548
+ const missingCategory = [];
2549
+ for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
2550
+ const ruleName = fullKey.replace(/^react-doctor\//, "");
2551
+ if (!(fullKey in RULE_CATEGORY_MAP)) missingCategory.push(fullKey);
2552
+ if (!(ruleName in RULE_HELP_MAP)) missingHelp.push(fullKey);
2553
+ }
2554
+ if (missingCategory.length > 0 || missingHelp.length > 0) {
2555
+ 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("; ");
2556
+ console.warn(`[react-doctor] rule-registration drift: ${detail}`);
2557
+ }
2558
+ };
2559
+ const runOxlint = async (options) => {
2560
+ const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
2561
+ validateRuleRegistration();
1987
2562
  if (includePaths !== void 0 && includePaths.length === 0) return [];
1988
- const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
2563
+ const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
2564
+ const configPath = path.join(configDirectory, "oxlintrc.json");
1989
2565
  const config = createOxlintConfig({
1990
2566
  pluginPath: resolvePluginPath(),
1991
2567
  framework,
1992
2568
  hasReactCompiler,
2569
+ hasTanStackQuery,
1993
2570
  customRulesOnly
1994
2571
  });
1995
- const restoreDisableDirectives = neutralizeDisableDirectives(rootDirectory, includePaths);
2572
+ const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
1996
2573
  try {
1997
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
2574
+ const fileHandle = fs.openSync(configPath, "wx", 384);
2575
+ try {
2576
+ fs.writeFileSync(fileHandle, JSON.stringify(config));
2577
+ } finally {
2578
+ fs.closeSync(fileHandle);
2579
+ }
1998
2580
  const baseArgs = [
1999
2581
  resolveOxlintBinary(),
2000
2582
  "-c",
@@ -2002,7 +2584,16 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
2002
2584
  "--format",
2003
2585
  "json"
2004
2586
  ];
2005
- if (hasTypeScript) baseArgs.push("--tsconfig", "./tsconfig.json");
2587
+ if (hasTypeScript) {
2588
+ const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
2589
+ if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
2590
+ }
2591
+ const combinedPatterns = collectIgnorePatterns(rootDirectory);
2592
+ if (combinedPatterns.length > 0) {
2593
+ const combinedIgnorePath = path.join(configDirectory, "combined.ignore");
2594
+ fs.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
2595
+ baseArgs.push("--ignore-path", combinedIgnorePath);
2596
+ }
2006
2597
  const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
2007
2598
  const allDiagnostics = [];
2008
2599
  for (const batch of fileBatches) {
@@ -2012,10 +2603,12 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
2012
2603
  return allDiagnostics;
2013
2604
  } finally {
2014
2605
  restoreDisableDirectives();
2015
- if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
2606
+ fs.rmSync(configDirectory, {
2607
+ recursive: true,
2608
+ force: true
2609
+ });
2016
2610
  }
2017
2611
  };
2018
-
2019
2612
  //#endregion
2020
2613
  //#region src/scan.ts
2021
2614
  const SEVERITY_ORDER = {
@@ -2054,7 +2647,7 @@ const printDiagnostics = (diagnostics, isVerbose) => {
2054
2647
  }
2055
2648
  };
2056
2649
  const formatElapsedTime = (elapsedMilliseconds) => {
2057
- if (elapsedMilliseconds < MILLISECONDS_PER_SECOND) return `${Math.round(elapsedMilliseconds)}ms`;
2650
+ if (elapsedMilliseconds < 1e3) return `${Math.round(elapsedMilliseconds)}ms`;
2058
2651
  return `${(elapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1)}s`;
2059
2652
  };
2060
2653
  const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
@@ -2076,15 +2669,15 @@ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
2076
2669
  };
2077
2670
  const writeDiagnosticsDirectory = (diagnostics) => {
2078
2671
  const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
2079
- mkdirSync(outputDirectory);
2672
+ mkdirSync(outputDirectory, { recursive: true });
2080
2673
  const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
2081
2674
  for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
2082
- writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
2675
+ writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics));
2083
2676
  return outputDirectory;
2084
2677
  };
2085
2678
  const buildScoreBarSegments = (score) => {
2086
- const filledCount = Math.round(score / PERFECT_SCORE * SCORE_BAR_WIDTH_CHARS);
2087
- const emptyCount = SCORE_BAR_WIDTH_CHARS - filledCount;
2679
+ const filledCount = Math.round(score / 100 * 50);
2680
+ const emptyCount = 50 - filledCount;
2088
2681
  return {
2089
2682
  filledSegment: "█".repeat(filledCount),
2090
2683
  emptySegment: "░".repeat(emptyCount)
@@ -2101,14 +2694,14 @@ const buildScoreBar = (score) => {
2101
2694
  const printScoreGauge = (score, label) => {
2102
2695
  const scoreDisplay = colorizeByScore(`${score}`, score);
2103
2696
  const labelDisplay = colorizeByScore(label, score);
2104
- logger.log(` ${scoreDisplay} / ${PERFECT_SCORE} ${labelDisplay}`);
2697
+ logger.log(` ${scoreDisplay} / 100 ${labelDisplay}`);
2105
2698
  logger.break();
2106
2699
  logger.log(` ${buildScoreBar(score)}`);
2107
2700
  logger.break();
2108
2701
  };
2109
2702
  const getDoctorFace = (score) => {
2110
- if (score >= SCORE_GOOD_THRESHOLD) return ["◠ ◠", " ▽ "];
2111
- if (score >= SCORE_OK_THRESHOLD) return ["• •", " ─ "];
2703
+ if (score >= 75) return ["◠ ◠", " ▽ "];
2704
+ if (score >= 50) return ["• •", " ─ "];
2112
2705
  return ["x x", " ▽ "];
2113
2706
  };
2114
2707
  const printBranding = (score) => {
@@ -2146,8 +2739,8 @@ const buildBrandingLines = (scoreResult, noScoreMessage) => {
2146
2739
  lines.push(createFramedLine("└─────┘", scoreColorizer("└─────┘")));
2147
2740
  lines.push(createFramedLine("React Doctor (www.react.doctor)", `React Doctor ${highlighter.dim("(www.react.doctor)")}`));
2148
2741
  lines.push(createFramedLine(""));
2149
- const scoreLinePlainText = `${scoreResult.score} / ${PERFECT_SCORE} ${scoreResult.label}`;
2150
- const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / ${PERFECT_SCORE} ${colorizeByScore(scoreResult.label, scoreResult.score)}`;
2742
+ const scoreLinePlainText = `${scoreResult.score} / 100 ${scoreResult.label}`;
2743
+ const scoreLineRenderedText = `${colorizeByScore(String(scoreResult.score), scoreResult.score)} / 100 ${colorizeByScore(scoreResult.label, scoreResult.score)}`;
2151
2744
  lines.push(createFramedLine(scoreLinePlainText, scoreLineRenderedText));
2152
2745
  lines.push(createFramedLine(""));
2153
2746
  lines.push(createFramedLine(buildPlainScoreBar(scoreResult.score), buildScoreBar(scoreResult.score)));
@@ -2198,23 +2791,23 @@ const printSummary = (diagnostics, elapsedMilliseconds, scoreResult, projectName
2198
2791
  logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
2199
2792
  }
2200
2793
  };
2201
- const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
2794
+ const resolveOxlintNode = async (isLintEnabled, isQuiet) => {
2202
2795
  if (!isLintEnabled) return null;
2203
2796
  const nodeResolution = resolveNodeForOxlint();
2204
2797
  if (nodeResolution) {
2205
- if (!nodeResolution.isCurrentNode && !isScoreOnly) {
2798
+ if (!nodeResolution.isCurrentNode && !isQuiet) {
2206
2799
  logger.warn(`Node ${process.version} is unsupported by oxlint. Using Node ${nodeResolution.version} from nvm.`);
2207
2800
  logger.break();
2208
2801
  }
2209
2802
  return nodeResolution.binaryPath;
2210
2803
  }
2211
- if (isScoreOnly) return null;
2804
+ if (isQuiet) return null;
2212
2805
  logger.warn(`Node ${process.version} is not compatible with oxlint (requires ${OXLINT_NODE_REQUIREMENT}). Lint checks will be skipped.`);
2213
2806
  if (isNvmInstalled() && process.stdin.isTTY) {
2214
2807
  const { shouldInstallNode } = await prompts({
2215
2808
  type: "confirm",
2216
2809
  name: "shouldInstallNode",
2217
- message: `Install Node ${OXLINT_RECOMMENDED_NODE_MAJOR} via nvm to enable lint checks?`,
2810
+ message: `Install Node 24 via nvm to enable lint checks?`,
2218
2811
  initial: true
2219
2812
  });
2220
2813
  if (shouldInstallNode) {
@@ -2231,8 +2824,8 @@ const resolveOxlintNode = async (isLintEnabled, isScoreOnly) => {
2231
2824
  logger.break();
2232
2825
  return null;
2233
2826
  }
2234
- } else if (isNvmInstalled()) logger.dim(` Run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
2235
- else logger.dim(` Install nvm (https://github.com/nvm-sh/nvm) and run: nvm install ${OXLINT_RECOMMENDED_NODE_MAJOR}`);
2827
+ } else if (isNvmInstalled()) logger.dim(` Run: nvm install 24`);
2828
+ else logger.dim(` Install nvm (https://github.com/nvm-sh/nvm) and run: nvm install 24`);
2236
2829
  logger.break();
2237
2830
  return null;
2238
2831
  };
@@ -2242,9 +2835,11 @@ const mergeScanOptions = (inputOptions, userConfig) => ({
2242
2835
  verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
2243
2836
  scoreOnly: inputOptions.scoreOnly ?? false,
2244
2837
  offline: inputOptions.offline ?? false,
2838
+ silent: inputOptions.silent ?? false,
2245
2839
  includePaths: inputOptions.includePaths ?? [],
2246
2840
  customRulesOnly: userConfig?.customRulesOnly ?? false,
2247
- share: userConfig?.share ?? true
2841
+ share: userConfig?.share ?? true,
2842
+ respectInlineDisables: inputOptions.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true
2248
2843
  });
2249
2844
  const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount) => {
2250
2845
  const frameworkLabel = formatFrameworkName(projectInfo.framework);
@@ -2263,35 +2858,61 @@ const printProjectDetection = (projectInfo, userConfig, isDiffMode, includePaths
2263
2858
  };
2264
2859
  const scan = async (directory, inputOptions = {}) => {
2265
2860
  const startTime = performance.now();
2266
- const projectInfo = discoverProject(directory);
2267
2861
  const userConfig = inputOptions.configOverride !== void 0 ? inputOptions.configOverride : loadConfig(directory);
2268
2862
  const options = mergeScanOptions(inputOptions, userConfig);
2863
+ const wasLoggerSilent = isLoggerSilent();
2864
+ const wasSpinnerSilent = isSpinnerSilent();
2865
+ if (options.silent) {
2866
+ setLoggerSilent(true);
2867
+ setSpinnerSilent(true);
2868
+ }
2869
+ try {
2870
+ return await runScan(directory, options, userConfig, startTime);
2871
+ } finally {
2872
+ if (options.silent) {
2873
+ setLoggerSilent(wasLoggerSilent);
2874
+ setSpinnerSilent(wasSpinnerSilent);
2875
+ }
2876
+ }
2877
+ };
2878
+ const runScan = async (directory, options, userConfig, startTime) => {
2879
+ const projectInfo = discoverProject(directory);
2269
2880
  const { includePaths } = options;
2270
2881
  const isDiffMode = includePaths.length > 0;
2271
- if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
2882
+ if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(directory));
2272
2883
  const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(directory, userConfig);
2273
2884
  const lintSourceFileCount = lintIncludePaths?.length ?? projectInfo.sourceFileCount;
2274
2885
  if (!options.scoreOnly) printProjectDetection(projectInfo, userConfig, isDiffMode, includePaths, lintSourceFileCount);
2275
2886
  let didLintFail = false;
2276
2887
  let didDeadCodeFail = false;
2277
- const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly);
2888
+ const resolvedNodeBinaryPath = await resolveOxlintNode(options.lint, options.scoreOnly || options.silent);
2278
2889
  if (options.lint && !resolvedNodeBinaryPath) didLintFail = true;
2279
2890
  const lintPromise = resolvedNodeBinaryPath ? (async () => {
2280
2891
  const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start();
2281
2892
  try {
2282
- const lintDiagnostics = await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, resolvedNodeBinaryPath, options.customRulesOnly);
2893
+ const lintDiagnostics = await runOxlint({
2894
+ rootDirectory: directory,
2895
+ hasTypeScript: projectInfo.hasTypeScript,
2896
+ framework: projectInfo.framework,
2897
+ hasReactCompiler: projectInfo.hasReactCompiler,
2898
+ hasTanStackQuery: projectInfo.hasTanStackQuery,
2899
+ includePaths: lintIncludePaths,
2900
+ nodeBinaryPath: resolvedNodeBinaryPath,
2901
+ customRulesOnly: options.customRulesOnly,
2902
+ respectInlineDisables: options.respectInlineDisables
2903
+ });
2283
2904
  lintSpinner?.succeed("Running lint checks.");
2284
2905
  return lintDiagnostics;
2285
2906
  } catch (error) {
2286
2907
  didLintFail = true;
2287
2908
  if (!options.scoreOnly) {
2288
- const errorMessage = error instanceof Error ? error.message : String(error);
2289
- if (errorMessage.includes("native binding")) {
2909
+ const lintErrorChain = formatErrorChain(error);
2910
+ if (lintErrorChain.includes("native binding")) {
2290
2911
  lintSpinner?.fail(`Lint checks failed — oxlint native binding not found (Node ${process.version}).`);
2291
2912
  logger.dim(` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`);
2292
2913
  } else {
2293
2914
  lintSpinner?.fail("Lint checks failed (non-fatal, skipping).");
2294
- logger.error(errorMessage);
2915
+ logger.error(lintErrorChain);
2295
2916
  }
2296
2917
  }
2297
2918
  return [];
@@ -2307,13 +2928,19 @@ const scan = async (directory, inputOptions = {}) => {
2307
2928
  didDeadCodeFail = true;
2308
2929
  if (!options.scoreOnly) {
2309
2930
  deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping).");
2310
- logger.error(String(error));
2931
+ logger.error(formatErrorChain(error));
2311
2932
  }
2312
2933
  return [];
2313
2934
  }
2314
2935
  })() : Promise.resolve([]);
2315
2936
  const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
2316
- const diagnostics = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig);
2937
+ const diagnostics = combineDiagnostics({
2938
+ lintDiagnostics,
2939
+ deadCodeDiagnostics,
2940
+ directory,
2941
+ isDiffMode,
2942
+ userConfig
2943
+ });
2317
2944
  const elapsedMilliseconds = performance.now() - startTime;
2318
2945
  const skippedChecks = [];
2319
2946
  if (didLintFail) skippedChecks.push("lint");
@@ -2321,14 +2948,17 @@ const scan = async (directory, inputOptions = {}) => {
2321
2948
  const hasSkippedChecks = skippedChecks.length > 0;
2322
2949
  const scoreResult = options.offline ? calculateScoreLocally(diagnostics) : await calculateScore(diagnostics);
2323
2950
  const noScoreMessage = OFFLINE_MESSAGE;
2951
+ const buildResult = () => ({
2952
+ diagnostics,
2953
+ score: scoreResult,
2954
+ skippedChecks,
2955
+ project: projectInfo,
2956
+ elapsedMilliseconds
2957
+ });
2324
2958
  if (options.scoreOnly) {
2325
2959
  if (scoreResult) logger.log(`${scoreResult.score}`);
2326
2960
  else logger.dim(noScoreMessage);
2327
- return {
2328
- diagnostics,
2329
- scoreResult,
2330
- skippedChecks
2331
- };
2961
+ return buildResult();
2332
2962
  }
2333
2963
  if (diagnostics.length === 0) {
2334
2964
  if (hasSkippedChecks) {
@@ -2343,11 +2973,7 @@ const scan = async (directory, inputOptions = {}) => {
2343
2973
  printBranding(scoreResult.score);
2344
2974
  printScoreGauge(scoreResult.score, scoreResult.label);
2345
2975
  } else logger.dim(` ${noScoreMessage}`);
2346
- return {
2347
- diagnostics,
2348
- scoreResult,
2349
- skippedChecks
2350
- };
2976
+ return buildResult();
2351
2977
  }
2352
2978
  printDiagnostics(diagnostics, options.verbose);
2353
2979
  const displayedSourceFileCount = isDiffMode ? includePaths.length : lintSourceFileCount;
@@ -2358,75 +2984,225 @@ const scan = async (directory, inputOptions = {}) => {
2358
2984
  logger.break();
2359
2985
  logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`);
2360
2986
  }
2987
+ return buildResult();
2988
+ };
2989
+ //#endregion
2990
+ //#region src/utils/summarize-diagnostics.ts
2991
+ const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
2992
+ let errorCount = 0;
2993
+ let warningCount = 0;
2994
+ const affectedFiles = /* @__PURE__ */ new Set();
2995
+ for (const diagnostic of diagnostics) {
2996
+ if (diagnostic.severity === "error") errorCount++;
2997
+ else warningCount++;
2998
+ affectedFiles.add(diagnostic.filePath);
2999
+ }
2361
3000
  return {
2362
- diagnostics,
2363
- scoreResult,
2364
- skippedChecks
3001
+ errorCount,
3002
+ warningCount,
3003
+ affectedFileCount: affectedFiles.size,
3004
+ totalDiagnosticCount: diagnostics.length,
3005
+ score: worstScore,
3006
+ scoreLabel: worstScoreLabel
2365
3007
  };
2366
3008
  };
2367
-
2368
3009
  //#endregion
2369
- //#region src/utils/get-diff-files.ts
2370
- const getCurrentBranch = (directory) => {
2371
- try {
2372
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
2373
- cwd: directory,
2374
- stdio: "pipe"
2375
- }).toString().trim();
2376
- return branch === "HEAD" ? null : branch;
2377
- } catch {
2378
- return null;
3010
+ //#region src/utils/build-json-report.ts
3011
+ const toJsonDiff = (diff) => {
3012
+ if (!diff) return null;
3013
+ return {
3014
+ baseBranch: diff.baseBranch,
3015
+ currentBranch: diff.currentBranch,
3016
+ changedFileCount: diff.changedFiles.length,
3017
+ isCurrentChanges: Boolean(diff.isCurrentChanges)
3018
+ };
3019
+ };
3020
+ const findWorstScoredProject = (projects) => {
3021
+ let worst = null;
3022
+ let worstScore = Number.POSITIVE_INFINITY;
3023
+ for (const project of projects) {
3024
+ const score = project.score?.score;
3025
+ if (typeof score !== "number") continue;
3026
+ if (score < worstScore) {
3027
+ worstScore = score;
3028
+ worst = project;
3029
+ }
2379
3030
  }
3031
+ return worst;
2380
3032
  };
2381
- const detectDefaultBranch = (directory) => {
3033
+ const buildJsonReport = (input) => {
3034
+ const projects = input.scans.map(({ directory, result }) => ({
3035
+ directory,
3036
+ project: result.project,
3037
+ diagnostics: result.diagnostics,
3038
+ score: result.score,
3039
+ skippedChecks: result.skippedChecks,
3040
+ elapsedMilliseconds: result.elapsedMilliseconds
3041
+ }));
3042
+ const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
3043
+ const worstScoredProject = findWorstScoredProject(projects);
3044
+ const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
3045
+ return {
3046
+ schemaVersion: 1,
3047
+ version: input.version,
3048
+ ok: true,
3049
+ directory: input.directory,
3050
+ mode: input.mode,
3051
+ diff: toJsonDiff(input.diff),
3052
+ projects,
3053
+ diagnostics: flattenedDiagnostics,
3054
+ summary,
3055
+ elapsedMilliseconds: input.totalElapsedMilliseconds,
3056
+ error: null
3057
+ };
3058
+ };
3059
+ //#endregion
3060
+ //#region src/utils/build-json-report-error.ts
3061
+ const safeStringify = (value) => {
2382
3062
  try {
2383
- return execSync("git symbolic-ref refs/remotes/origin/HEAD", {
2384
- cwd: directory,
2385
- stdio: "pipe"
2386
- }).toString().trim().replace("refs/remotes/origin/", "");
3063
+ return String(value);
2387
3064
  } catch {
2388
- for (const candidate of DEFAULT_BRANCH_CANDIDATES) try {
2389
- execSync(`git rev-parse --verify ${candidate}`, {
2390
- cwd: directory,
2391
- stdio: "pipe"
2392
- });
2393
- return candidate;
2394
- } catch {}
2395
- return null;
3065
+ return "Unrepresentable error";
2396
3066
  }
2397
3067
  };
2398
- const getChangedFilesSinceBranch = (directory, baseBranch) => {
3068
+ const safeGetErrorChain = (error) => {
2399
3069
  try {
2400
- const output = execSync(`git diff --name-only --diff-filter=ACMR --relative ${execSync(`git merge-base ${baseBranch} HEAD`, {
2401
- cwd: directory,
2402
- stdio: "pipe"
2403
- }).toString().trim()}`, {
2404
- cwd: directory,
2405
- stdio: "pipe"
2406
- }).toString().trim();
2407
- if (!output) return [];
2408
- return output.split("\n").filter(Boolean);
3070
+ return getErrorChainMessages(error);
2409
3071
  } catch {
2410
- return [];
3072
+ return [safeStringify(error)];
3073
+ }
3074
+ };
3075
+ const buildJsonReportError = (input) => {
3076
+ const chain = safeGetErrorChain(input.error);
3077
+ const errorPayload = input.error instanceof Error ? {
3078
+ message: input.error.message || input.error.name || "Error",
3079
+ name: input.error.name || "Error",
3080
+ chain
3081
+ } : {
3082
+ message: safeStringify(input.error),
3083
+ name: "Error",
3084
+ chain
3085
+ };
3086
+ return {
3087
+ schemaVersion: 1,
3088
+ version: input.version,
3089
+ ok: false,
3090
+ directory: input.directory,
3091
+ mode: input.mode ?? "full",
3092
+ diff: null,
3093
+ projects: [],
3094
+ diagnostics: [],
3095
+ summary: {
3096
+ errorCount: 0,
3097
+ warningCount: 0,
3098
+ affectedFileCount: 0,
3099
+ totalDiagnosticCount: 0,
3100
+ score: null,
3101
+ scoreLabel: null
3102
+ },
3103
+ elapsedMilliseconds: input.elapsedMilliseconds,
3104
+ error: errorPayload
3105
+ };
3106
+ };
3107
+ //#endregion
3108
+ //#region src/utils/get-diff-files.ts
3109
+ const runGit = (cwd, args) => {
3110
+ const result = spawnSync("git", args, {
3111
+ cwd,
3112
+ stdio: [
3113
+ "ignore",
3114
+ "pipe",
3115
+ "pipe"
3116
+ ],
3117
+ encoding: "utf-8"
3118
+ });
3119
+ if (result.error || result.status !== 0) return null;
3120
+ return result.stdout.toString().trim();
3121
+ };
3122
+ const getCurrentBranch = (directory) => {
3123
+ const branch = runGit(directory, [
3124
+ "rev-parse",
3125
+ "--abbrev-ref",
3126
+ "HEAD"
3127
+ ]);
3128
+ if (!branch) return null;
3129
+ return branch === "HEAD" ? null : branch;
3130
+ };
3131
+ const detectDefaultBranch = (directory) => {
3132
+ const reference = runGit(directory, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
3133
+ if (reference) return reference.replace("refs/remotes/origin/", "");
3134
+ const output = runGit(directory, [
3135
+ "for-each-ref",
3136
+ "--format=%(refname:short)",
3137
+ ...DEFAULT_BRANCH_CANDIDATES.map((candidate) => `refs/heads/${candidate}`)
3138
+ ]);
3139
+ if (output) {
3140
+ const firstLine = output.split("\n")[0]?.trim();
3141
+ if (firstLine) return firstLine;
2411
3142
  }
3143
+ return null;
3144
+ };
3145
+ const branchExists = (directory, branch) => {
3146
+ const result = spawnSync("git", [
3147
+ "rev-parse",
3148
+ "--verify",
3149
+ branch
3150
+ ], {
3151
+ cwd: directory,
3152
+ stdio: [
3153
+ "ignore",
3154
+ "pipe",
3155
+ "pipe"
3156
+ ]
3157
+ });
3158
+ return !result.error && result.status === 0;
3159
+ };
3160
+ const runGitNullSeparated = (cwd, args) => {
3161
+ const result = spawnSync("git", args, {
3162
+ cwd,
3163
+ stdio: [
3164
+ "ignore",
3165
+ "pipe",
3166
+ "pipe"
3167
+ ],
3168
+ encoding: "utf-8"
3169
+ });
3170
+ if (result.error || result.status !== 0) return null;
3171
+ return result.stdout.toString().split("\0").filter((filePath) => filePath.length > 0);
3172
+ };
3173
+ const getChangedFilesSinceBranch = (directory, baseBranch) => {
3174
+ const mergeBase = runGit(directory, [
3175
+ "merge-base",
3176
+ baseBranch,
3177
+ "HEAD"
3178
+ ]);
3179
+ if (mergeBase === null) return null;
3180
+ return runGitNullSeparated(directory, [
3181
+ "diff",
3182
+ "-z",
3183
+ "--name-only",
3184
+ "--diff-filter=ACMR",
3185
+ "--relative",
3186
+ mergeBase
3187
+ ]);
2412
3188
  };
2413
3189
  const getUncommittedChangedFiles = (directory) => {
2414
- try {
2415
- const output = execSync("git diff --name-only --diff-filter=ACMR --relative HEAD", {
2416
- cwd: directory,
2417
- stdio: "pipe"
2418
- }).toString().trim();
2419
- if (!output) return [];
2420
- return output.split("\n").filter(Boolean);
2421
- } catch {
2422
- return [];
2423
- }
3190
+ return runGitNullSeparated(directory, [
3191
+ "diff",
3192
+ "-z",
3193
+ "--name-only",
3194
+ "--diff-filter=ACMR",
3195
+ "--relative",
3196
+ "HEAD"
3197
+ ]) ?? [];
2424
3198
  };
2425
3199
  const getDiffInfo = (directory, explicitBaseBranch) => {
3200
+ if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) throw new Error("Diff base branch cannot be empty.");
2426
3201
  const currentBranch = getCurrentBranch(directory);
2427
3202
  if (!currentBranch) return null;
2428
3203
  const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
2429
3204
  if (!baseBranch) return null;
3205
+ if (explicitBaseBranch && !branchExists(directory, explicitBaseBranch)) throw new Error(`Diff base branch "${explicitBaseBranch}" does not exist (run \`git fetch\` to update remote refs).`);
2430
3206
  if (currentBranch === baseBranch) {
2431
3207
  const uncommittedFiles = getUncommittedChangedFiles(directory);
2432
3208
  if (uncommittedFiles.length === 0) return null;
@@ -2437,20 +3213,22 @@ const getDiffInfo = (directory, explicitBaseBranch) => {
2437
3213
  isCurrentChanges: true
2438
3214
  };
2439
3215
  }
3216
+ const changedFiles = getChangedFilesSinceBranch(directory, baseBranch);
3217
+ if (changedFiles === null) return null;
2440
3218
  return {
2441
3219
  currentBranch,
2442
3220
  baseBranch,
2443
- changedFiles: getChangedFilesSinceBranch(directory, baseBranch)
3221
+ changedFiles
2444
3222
  };
2445
3223
  };
2446
3224
  const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
2447
-
2448
3225
  //#endregion
2449
3226
  //#region src/utils/get-staged-files.ts
2450
3227
  const getStagedFilePaths = (directory) => {
2451
3228
  const result = spawnSync("git", [
2452
3229
  "diff",
2453
3230
  "--cached",
3231
+ "-z",
2454
3232
  "--name-only",
2455
3233
  "--diff-filter=ACMR",
2456
3234
  "--relative"
@@ -2460,9 +3238,9 @@ const getStagedFilePaths = (directory) => {
2460
3238
  maxBuffer: GIT_SHOW_MAX_BUFFER_BYTES
2461
3239
  });
2462
3240
  if (result.error || result.status !== 0) return [];
2463
- const output = result.stdout.toString().trim();
3241
+ const output = result.stdout.toString();
2464
3242
  if (!output) return [];
2465
- return output.split("\n").filter(Boolean);
3243
+ return output.split("\0").filter((filePath) => filePath.length > 0);
2466
3244
  };
2467
3245
  const readStagedContent = (directory, relativePath) => {
2468
3246
  const result = spawnSync("git", ["show", `:${relativePath}`], {
@@ -2474,6 +3252,18 @@ const readStagedContent = (directory, relativePath) => {
2474
3252
  return result.stdout.toString();
2475
3253
  };
2476
3254
  const getStagedSourceFiles = (directory) => getStagedFilePaths(directory).filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
3255
+ const PROJECT_CONFIG_FILENAMES = [
3256
+ "tsconfig.json",
3257
+ "tsconfig.base.json",
3258
+ "package.json",
3259
+ "react-doctor.config.json",
3260
+ "knip.json",
3261
+ "knip.jsonc",
3262
+ ".knip.json",
3263
+ ".knip.jsonc",
3264
+ "oxlint.json",
3265
+ ".oxlintrc.json"
3266
+ ];
2477
3267
  const materializeStagedFiles = (directory, stagedFiles, tempDirectory) => {
2478
3268
  const materializedFiles = [];
2479
3269
  for (const relativePath of stagedFiles) {
@@ -2484,11 +3274,7 @@ const materializeStagedFiles = (directory, stagedFiles, tempDirectory) => {
2484
3274
  fs.writeFileSync(targetPath, content);
2485
3275
  materializedFiles.push(relativePath);
2486
3276
  }
2487
- for (const configFilename of [
2488
- "tsconfig.json",
2489
- "package.json",
2490
- "react-doctor.config.json"
2491
- ]) {
3277
+ for (const configFilename of PROJECT_CONFIG_FILENAMES) {
2492
3278
  const sourcePath = path.join(directory, configFilename);
2493
3279
  const targetPath = path.join(tempDirectory, configFilename);
2494
3280
  if (fs.existsSync(sourcePath) && !fs.existsSync(targetPath)) fs.cpSync(sourcePath, targetPath);
@@ -2506,21 +3292,23 @@ const materializeStagedFiles = (directory, stagedFiles, tempDirectory) => {
2506
3292
  }
2507
3293
  };
2508
3294
  };
2509
-
2510
3295
  //#endregion
2511
3296
  //#region src/utils/handle-error.ts
2512
3297
  const DEFAULT_HANDLE_ERROR_OPTIONS = { shouldExit: true };
2513
3298
  const handleError = (error, options = DEFAULT_HANDLE_ERROR_OPTIONS) => {
2514
3299
  logger.break();
2515
3300
  logger.error("Something went wrong. Please check the error below for more details.");
2516
- logger.error("If the problem persists, please open an issue on GitHub.");
3301
+ logger.error(`If the problem persists, please open an issue at ${CANONICAL_GITHUB_URL}/issues.`);
2517
3302
  logger.error("");
2518
- if (error instanceof Error) logger.error(error.message);
3303
+ logger.error(formatErrorChain(error));
2519
3304
  logger.break();
2520
3305
  if (options.shouldExit) process.exit(1);
2521
3306
  process.exitCode = 1;
2522
3307
  };
2523
-
3308
+ //#endregion
3309
+ //#region src/utils/annotation-encoding.ts
3310
+ const encodeAnnotationProperty = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A").replaceAll(":", "%3A").replaceAll(",", "%2C");
3311
+ const encodeAnnotationMessage = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
2524
3312
  //#endregion
2525
3313
  //#region src/utils/select-projects.ts
2526
3314
  const selectProjects = async (rootDirectory, projectFlag, skipPrompts) => {
@@ -2568,10 +3356,9 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
2568
3356
  });
2569
3357
  return selectedDirectories;
2570
3358
  };
2571
-
2572
3359
  //#endregion
2573
3360
  //#region src/cli.ts
2574
- const VERSION = "0.0.41";
3361
+ const VERSION = "0.0.44";
2575
3362
  const VALID_FAIL_ON_LEVELS = new Set([
2576
3363
  "error",
2577
3364
  "warning",
@@ -2584,48 +3371,104 @@ const shouldFailForDiagnostics = (diagnostics, failOnLevel) => {
2584
3371
  return diagnostics.some((diagnostic) => diagnostic.severity === "error");
2585
3372
  };
2586
3373
  const resolveFailOnLevel = (programInstance, flags, userConfig) => {
2587
- const resolvedFailOn = programInstance.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
2588
- return isValidFailOnLevel(resolvedFailOn) ? resolvedFailOn : "none";
3374
+ const sourceValue = programInstance.getOptionValueSource("failOn") === "cli" ? flags.failOn : userConfig?.failOn ?? flags.failOn;
3375
+ if (isValidFailOnLevel(sourceValue)) return sourceValue;
3376
+ logger.warn(`Invalid failOn level "${sourceValue}". Expected one of: error, warning, none. Falling back to "none".`);
3377
+ return "none";
2589
3378
  };
2590
- const printAnnotations = (diagnostics) => {
3379
+ const printAnnotations = (diagnostics, routeToStderr) => {
3380
+ const writeLine = routeToStderr ? (line) => process.stderr.write(`${line}\n`) : (line) => process.stdout.write(`${line}\n`);
2591
3381
  for (const diagnostic of diagnostics) {
2592
3382
  const level = diagnostic.severity === "error" ? "error" : "warning";
2593
3383
  const title = `${diagnostic.plugin}/${diagnostic.rule}`;
2594
- const fileLocation = diagnostic.line > 0 ? `file=${diagnostic.filePath},line=${diagnostic.line}` : `file=${diagnostic.filePath}`;
2595
- console.log(`::${level} ${fileLocation},title=${title}::${diagnostic.message}`);
3384
+ writeLine(`::${level} ${`file=${encodeAnnotationProperty(diagnostic.filePath)}`}${diagnostic.line > 0 ? `,line=${diagnostic.line}` : ""}${`,title=${encodeAnnotationProperty(title)}`}::${encodeAnnotationMessage(diagnostic.message)}`);
2596
3385
  }
2597
3386
  };
3387
+ let isJsonModeActive = false;
3388
+ let resolvedDirectoryForCancel = null;
3389
+ let cancelStartTime = 0;
3390
+ let currentReportMode = "full";
2598
3391
  const exitGracefully = () => {
3392
+ if (isJsonModeActive) {
3393
+ writeJsonReport(buildJsonReportError({
3394
+ version: VERSION,
3395
+ directory: resolvedDirectoryForCancel ?? process.cwd(),
3396
+ error: /* @__PURE__ */ new Error("Scan cancelled by user (SIGINT/SIGTERM)"),
3397
+ elapsedMilliseconds: performance.now() - cancelStartTime,
3398
+ mode: currentReportMode
3399
+ }));
3400
+ process.exit(130);
3401
+ }
2599
3402
  logger.break();
2600
3403
  logger.log("Cancelled.");
2601
3404
  logger.break();
2602
- process.exit(0);
3405
+ process.exit(130);
2603
3406
  };
2604
3407
  process.on("SIGINT", exitGracefully);
2605
3408
  process.on("SIGTERM", exitGracefully);
2606
- const AUTOMATED_ENVIRONMENT_VARIABLES = [
3409
+ const NON_INTERACTIVE_ENVIRONMENT_VARIABLES = [
2607
3410
  "CI",
3411
+ "GITHUB_ACTIONS",
3412
+ "GITLAB_CI",
3413
+ "BUILDKITE",
3414
+ "JENKINS_URL",
3415
+ "TF_BUILD",
3416
+ "CODEBUILD_BUILD_ID",
3417
+ "TEAMCITY_VERSION",
3418
+ "BITBUCKET_BUILD_NUMBER",
3419
+ "CIRCLECI",
3420
+ "TRAVIS",
3421
+ "DRONE",
2608
3422
  "CLAUDECODE",
3423
+ "CLAUDE_CODE",
2609
3424
  "CURSOR_AGENT",
2610
3425
  "CODEX_CI",
2611
3426
  "OPENCODE",
2612
3427
  "AMP_HOME"
2613
3428
  ];
2614
- const isAutomatedEnvironment = () => AUTOMATED_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
3429
+ const CI_ENVIRONMENT_VARIABLES = [
3430
+ "GITHUB_ACTIONS",
3431
+ "GITLAB_CI",
3432
+ "CIRCLECI"
3433
+ ];
3434
+ const isNonInteractiveEnvironment = () => NON_INTERACTIVE_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable]));
3435
+ const isCiEnvironment = () => CI_ENVIRONMENT_VARIABLES.some((envVariable) => Boolean(process.env[envVariable])) || process.env.CI === "true";
2615
3436
  const resolveCliScanOptions = (flags, userConfig, programInstance) => {
2616
3437
  const isCliOverride = (optionName) => programInstance.getOptionValueSource(optionName) === "cli";
2617
3438
  return {
2618
3439
  lint: isCliOverride("lint") ? flags.lint : userConfig?.lint ?? true,
2619
3440
  deadCode: isCliOverride("deadCode") ? flags.deadCode : userConfig?.deadCode ?? true,
2620
- verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : userConfig?.verbose ?? false,
3441
+ verbose: isCliOverride("verbose") ? flags.verbose : userConfig?.verbose ?? false,
2621
3442
  scoreOnly: flags.score,
2622
- offline: flags.offline
3443
+ offline: flags.offline || isCiEnvironment(),
3444
+ silent: flags.json,
3445
+ respectInlineDisables: isCliOverride("respectInlineDisables") ? flags.respectInlineDisables : userConfig?.respectInlineDisables ?? true
2623
3446
  };
2624
3447
  };
2625
- const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly) => {
3448
+ let isCompactJsonOutput = false;
3449
+ const writeJsonReport = (report) => {
3450
+ const serialized = isCompactJsonOutput ? JSON.stringify(report) : JSON.stringify(report, null, 2);
3451
+ process.stdout.write(`${serialized}\n`);
3452
+ };
3453
+ const coerceDiffValue = (value) => {
3454
+ if (value === void 0) return void 0;
3455
+ if (typeof value === "boolean") return value;
3456
+ if (typeof value === "string") {
3457
+ if (value.length === 0) return void 0;
3458
+ if (value === "false") return false;
3459
+ if (value === "true") return true;
3460
+ return value;
3461
+ }
3462
+ process.stderr.write(`[react-doctor] invalid diff value (expected boolean or string): ${typeof value}. Falling back to no diff.\n`);
3463
+ };
3464
+ const resolveEffectiveDiff = (flags, userConfig, programInstance) => {
3465
+ if (flags.full) return false;
3466
+ return coerceDiffValue(programInstance.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff);
3467
+ };
3468
+ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isQuiet) => {
2626
3469
  if (effectiveDiff !== void 0 && effectiveDiff !== false) {
2627
3470
  if (diffInfo) return true;
2628
- if (!isScoreOnly) {
3471
+ if (!isQuiet) {
2629
3472
  logger.warn("No feature branch or uncommitted changes detected. Running full scan.");
2630
3473
  logger.break();
2631
3474
  }
@@ -2635,81 +3478,139 @@ const resolveDiffMode = async (diffInfo, effectiveDiff, shouldSkipPrompts, isSco
2635
3478
  const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
2636
3479
  if (changedSourceFiles.length === 0) return false;
2637
3480
  if (shouldSkipPrompts) return false;
2638
- if (isScoreOnly) return false;
3481
+ if (isQuiet) return false;
2639
3482
  const { shouldScanChangedOnly } = await prompts({
2640
3483
  type: "confirm",
2641
3484
  name: "shouldScanChangedOnly",
2642
- 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?`,
3485
+ 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?`,
2643
3486
  initial: true
2644
3487
  });
2645
3488
  return Boolean(shouldScanChangedOnly);
2646
3489
  };
2647
- 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) => {
3490
+ const validateModeFlags = (flags) => {
3491
+ const coercedDiff = coerceDiffValue(flags.diff);
3492
+ const exclusiveModes = [flags.staged ? "--staged" : null, coercedDiff !== void 0 && coercedDiff !== false ? "--diff" : null].filter((modeName) => modeName !== null);
3493
+ if (exclusiveModes.length > 1) throw new Error(`Cannot combine ${exclusiveModes.join(" and ")}; pick one mode.`);
3494
+ if (flags.yes && flags.full) throw new Error("Cannot combine --yes and --full; pick one.");
3495
+ if (flags.score && flags.json) throw new Error("Cannot combine --score and --json; pick one output mode.");
3496
+ if (flags.annotations && (flags.json || flags.score)) throw new Error("--annotations cannot be combined with --json or --score.");
3497
+ };
3498
+ 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) => {
2648
3499
  const isScoreOnly = flags.score;
3500
+ const isJsonMode = flags.json;
3501
+ const isQuiet = isScoreOnly || isJsonMode;
3502
+ const resolvedDirectory = path.resolve(directory);
3503
+ const jsonStartTime = performance.now();
3504
+ isJsonModeActive = isJsonMode;
3505
+ isCompactJsonOutput = Boolean(flags.jsonCompact);
3506
+ resolvedDirectoryForCancel = resolvedDirectory;
3507
+ cancelStartTime = jsonStartTime;
3508
+ if (isJsonMode) setLoggerSilent(true);
2649
3509
  try {
2650
- const resolvedDirectory = path.resolve(directory);
3510
+ validateModeFlags(flags);
2651
3511
  const userConfig = loadConfig(resolvedDirectory);
2652
- if (!isScoreOnly) {
3512
+ if (!isQuiet) {
2653
3513
  logger.log(`react-doctor v${VERSION}`);
2654
3514
  logger.break();
2655
3515
  }
2656
3516
  const scanOptions = resolveCliScanOptions(flags, userConfig, program);
2657
- const shouldSkipPrompts = flags.yes || flags.no || isAutomatedEnvironment() || !process.stdin.isTTY;
3517
+ const shouldSkipPrompts = flags.yes || flags.full || isJsonMode || isNonInteractiveEnvironment() || !process.stdin.isTTY;
3518
+ if (!flags.offline && isCiEnvironment() && !isQuiet) {
3519
+ logger.dim("CI detected — scoring locally.");
3520
+ logger.break();
3521
+ }
2658
3522
  if (flags.staged) {
3523
+ currentReportMode = "staged";
2659
3524
  const stagedFiles = getStagedSourceFiles(resolvedDirectory);
2660
3525
  if (stagedFiles.length === 0) {
2661
- if (!isScoreOnly) logger.dim("No staged source files found.");
3526
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3527
+ version: VERSION,
3528
+ directory: resolvedDirectory,
3529
+ mode: "staged",
3530
+ diff: null,
3531
+ scans: [],
3532
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
3533
+ }));
3534
+ else if (!isScoreOnly) logger.dim("No staged source files found.");
2662
3535
  return;
2663
3536
  }
2664
- if (!isScoreOnly) {
3537
+ if (!isQuiet) {
2665
3538
  logger.log(`Scanning ${highlighter.info(`${stagedFiles.length}`)} staged files...`);
2666
3539
  logger.break();
2667
3540
  }
2668
- const snapshot = materializeStagedFiles(resolvedDirectory, stagedFiles, mkdtempSync(path.join(tmpdir(), "react-doctor-staged-")));
3541
+ let tempDirectory = null;
3542
+ let cleanupSnapshot = null;
2669
3543
  try {
2670
- const remappedDiagnostics = (await scan(snapshot.tempDirectory, {
3544
+ tempDirectory = mkdtempSync(path.join(tmpdir(), "react-doctor-staged-"));
3545
+ const snapshot = materializeStagedFiles(resolvedDirectory, stagedFiles, tempDirectory);
3546
+ cleanupSnapshot = snapshot.cleanup;
3547
+ const scanResult = await scan(snapshot.tempDirectory, {
2671
3548
  ...scanOptions,
2672
3549
  includePaths: snapshot.stagedFiles,
2673
3550
  configOverride: userConfig
2674
- })).diagnostics.map((diagnostic) => ({
3551
+ });
3552
+ const remappedDiagnostics = scanResult.diagnostics.map((diagnostic) => ({
2675
3553
  ...diagnostic,
2676
- filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replace(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
3554
+ filePath: path.isAbsolute(diagnostic.filePath) ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, resolvedDirectory) : diagnostic.filePath
3555
+ }));
3556
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3557
+ version: VERSION,
3558
+ directory: resolvedDirectory,
3559
+ mode: "staged",
3560
+ diff: null,
3561
+ scans: [{
3562
+ directory: resolvedDirectory,
3563
+ result: {
3564
+ ...scanResult,
3565
+ diagnostics: remappedDiagnostics,
3566
+ project: {
3567
+ ...scanResult.project,
3568
+ rootDirectory: resolvedDirectory
3569
+ }
3570
+ }
3571
+ }],
3572
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
2677
3573
  }));
2678
- if (flags.annotations) printAnnotations(remappedDiagnostics);
3574
+ if (flags.annotations) printAnnotations(remappedDiagnostics, isJsonMode);
2679
3575
  if (shouldFailForDiagnostics(remappedDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
2680
3576
  } finally {
2681
- snapshot.cleanup();
3577
+ cleanupSnapshot?.();
2682
3578
  }
2683
3579
  return;
2684
3580
  }
2685
3581
  const projectDirectories = await selectProjects(resolvedDirectory, flags.project, shouldSkipPrompts);
2686
- const effectiveDiff = program.getOptionValueSource("diff") === "cli" ? flags.diff : userConfig?.diff;
3582
+ const effectiveDiff = resolveEffectiveDiff(flags, userConfig, program);
2687
3583
  const explicitBaseBranch = typeof effectiveDiff === "string" ? effectiveDiff : void 0;
2688
- const diffInfo = getDiffInfo(resolvedDirectory, explicitBaseBranch);
2689
- const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isScoreOnly);
2690
- if (isDiffMode && diffInfo && !isScoreOnly) {
3584
+ const diffInfo = effectiveDiff !== void 0 && effectiveDiff !== false || !shouldSkipPrompts && !isQuiet ? getDiffInfo(resolvedDirectory, explicitBaseBranch) : null;
3585
+ const isDiffMode = await resolveDiffMode(diffInfo, effectiveDiff, shouldSkipPrompts, isQuiet);
3586
+ currentReportMode = isDiffMode ? "diff" : "full";
3587
+ if (isDiffMode && diffInfo && !isQuiet) {
2691
3588
  if (diffInfo.isCurrentChanges) logger.log("Scanning uncommitted changes");
2692
3589
  else logger.log(`Scanning changes: ${highlighter.info(diffInfo.currentBranch)} → ${highlighter.info(diffInfo.baseBranch)}`);
2693
3590
  logger.break();
2694
3591
  }
2695
3592
  const allDiagnostics = [];
3593
+ const completedScans = [];
2696
3594
  for (const projectDirectory of projectDirectories) {
2697
3595
  let includePaths;
2698
3596
  if (isDiffMode) {
2699
- const projectDiffInfo = getDiffInfo(projectDirectory, explicitBaseBranch);
3597
+ const projectDiffInfo = projectDirectory === resolvedDirectory ? diffInfo : getDiffInfo(projectDirectory, explicitBaseBranch);
2700
3598
  if (projectDiffInfo) {
2701
3599
  const changedSourceFiles = filterSourceFiles(projectDiffInfo.changedFiles);
2702
3600
  if (changedSourceFiles.length === 0) {
2703
- if (!isScoreOnly) {
3601
+ if (!isQuiet) {
2704
3602
  logger.dim(`No changed source files in ${projectDirectory}, skipping.`);
2705
3603
  logger.break();
2706
3604
  }
2707
3605
  continue;
2708
3606
  }
2709
3607
  includePaths = changedSourceFiles;
3608
+ } else if (!isQuiet) {
3609
+ logger.dim(`Cannot detect diff for ${projectDirectory} (not a git repository?) — scanning all files.`);
3610
+ logger.break();
2710
3611
  }
2711
3612
  }
2712
- if (!isScoreOnly) {
3613
+ if (!isQuiet) {
2713
3614
  logger.dim(`Scanning ${projectDirectory}...`);
2714
3615
  logger.break();
2715
3616
  }
@@ -2718,29 +3619,81 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2718
3619
  includePaths
2719
3620
  });
2720
3621
  allDiagnostics.push(...scanResult.diagnostics);
2721
- if (!isScoreOnly) logger.break();
3622
+ completedScans.push({
3623
+ directory: projectDirectory,
3624
+ result: scanResult
3625
+ });
3626
+ if (!isQuiet) logger.break();
2722
3627
  }
2723
- if (flags.annotations) printAnnotations(allDiagnostics);
3628
+ const reportMode = isDiffMode ? "diff" : "full";
3629
+ if (isJsonMode) writeJsonReport(buildJsonReport({
3630
+ version: VERSION,
3631
+ directory: resolvedDirectory,
3632
+ mode: reportMode,
3633
+ diff: isDiffMode ? diffInfo : null,
3634
+ scans: completedScans,
3635
+ totalElapsedMilliseconds: performance.now() - jsonStartTime
3636
+ }));
3637
+ if (flags.annotations) printAnnotations(allDiagnostics, isJsonMode);
2724
3638
  if (shouldFailForDiagnostics(allDiagnostics, resolveFailOnLevel(program, flags, userConfig))) process.exitCode = 1;
2725
3639
  } catch (error) {
2726
- handleError(error);
3640
+ try {
3641
+ if (isJsonMode) {
3642
+ writeJsonReport(buildJsonReportError({
3643
+ version: VERSION,
3644
+ directory: resolvedDirectory,
3645
+ error,
3646
+ elapsedMilliseconds: performance.now() - jsonStartTime,
3647
+ mode: currentReportMode
3648
+ }));
3649
+ process.exitCode = 1;
3650
+ return;
3651
+ }
3652
+ handleError(error);
3653
+ } catch {
3654
+ if (isJsonMode) process.stdout.write("{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n");
3655
+ process.exitCode = 1;
3656
+ }
2727
3657
  }
2728
3658
  }).addHelpText("after", `
3659
+ ${highlighter.dim("Configuration:")}
3660
+ Place a ${highlighter.info("react-doctor.config.json")} (or ${highlighter.info("\"reactDoctor\"")} key in your package.json) in the project root.
3661
+ CLI flags always override config values. See the README for the full schema.
3662
+
2729
3663
  ${highlighter.dim("Learn more:")}
2730
- ${highlighter.info("https://github.com/millionco/react-doctor")}
3664
+ ${highlighter.info(CANONICAL_GITHUB_URL)}
2731
3665
  `);
2732
- 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) => {
3666
+ 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) => {
2733
3667
  try {
2734
- await runInstallSkill({ yes: options.yes });
3668
+ await runInstallSkill({
3669
+ yes: options.yes,
3670
+ dryRun: options.dryRun
3671
+ });
2735
3672
  } catch (error) {
2736
3673
  handleError(error);
2737
3674
  }
2738
3675
  });
2739
- const main$1 = async () => {
2740
- await program.parseAsync();
2741
- };
2742
- main$1();
2743
-
3676
+ process.stdout.on("error", (error) => {
3677
+ if (error.code === "EPIPE") process.exit(0);
3678
+ });
3679
+ program.parseAsync().catch((error) => {
3680
+ if (isJsonModeActive) {
3681
+ try {
3682
+ writeJsonReport(buildJsonReportError({
3683
+ version: VERSION,
3684
+ directory: resolvedDirectoryForCancel ?? process.cwd(),
3685
+ error,
3686
+ elapsedMilliseconds: performance.now() - cancelStartTime,
3687
+ mode: currentReportMode
3688
+ }));
3689
+ } catch {
3690
+ process.stdout.write("{\"schemaVersion\":1,\"ok\":false,\"error\":{\"message\":\"Internal error\",\"name\":\"Error\",\"chain\":[]}}\n");
3691
+ }
3692
+ process.exit(1);
3693
+ }
3694
+ handleError(error);
3695
+ });
2744
3696
  //#endregion
2745
- export { };
3697
+ export {};
3698
+
2746
3699
  //# sourceMappingURL=cli.js.map