react-doctor 0.0.2 → 0.0.4

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 ADDED
@@ -0,0 +1,983 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import path, { join } from "node:path";
4
+ import { Command } from "commander";
5
+ import pc from "picocolors";
6
+ import { randomUUID } from "node:crypto";
7
+ import fs, { mkdirSync, writeFileSync } from "node:fs";
8
+ import os, { tmpdir } from "node:os";
9
+ import { performance } from "node:perf_hooks";
10
+ import { execSync, spawn, spawnSync } from "node:child_process";
11
+ import { main } from "knip";
12
+ import { createOptions } from "knip/session";
13
+ import { fileURLToPath } from "node:url";
14
+ import ora from "ora";
15
+ import basePrompts from "prompts";
16
+
17
+ //#region src/utils/highlighter.ts
18
+ const highlighter = {
19
+ error: pc.red,
20
+ warn: pc.yellow,
21
+ info: pc.cyan,
22
+ success: pc.green,
23
+ dim: pc.dim
24
+ };
25
+
26
+ //#endregion
27
+ //#region src/utils/logger.ts
28
+ const logger = {
29
+ error(...args) {
30
+ console.log(highlighter.error(args.join(" ")));
31
+ },
32
+ warn(...args) {
33
+ console.log(highlighter.warn(args.join(" ")));
34
+ },
35
+ info(...args) {
36
+ console.log(highlighter.info(args.join(" ")));
37
+ },
38
+ success(...args) {
39
+ console.log(highlighter.success(args.join(" ")));
40
+ },
41
+ dim(...args) {
42
+ console.log(highlighter.dim(args.join(" ")));
43
+ },
44
+ log(...args) {
45
+ console.log(args.join(" "));
46
+ },
47
+ break() {
48
+ console.log("");
49
+ }
50
+ };
51
+
52
+ //#endregion
53
+ //#region src/utils/handle-error.ts
54
+ const handleError = (error) => {
55
+ logger.break();
56
+ logger.error("Something went wrong. Please check the error below for more details.");
57
+ logger.error("If the problem persists, please open an issue on GitHub.");
58
+ logger.error("");
59
+ if (error instanceof Error) logger.error(error.message);
60
+ logger.break();
61
+ process.exit(1);
62
+ };
63
+
64
+ //#endregion
65
+ //#region src/constants.ts
66
+ const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
67
+ const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
68
+ const SEPARATOR_LENGTH_CHARS = 62;
69
+ const ERROR_PREVIEW_LENGTH_CHARS = 200;
70
+
71
+ //#endregion
72
+ //#region src/utils/read-package-json.ts
73
+ const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
74
+
75
+ //#endregion
76
+ //#region src/utils/discover-project.ts
77
+ const REACT_COMPILER_PACKAGES = new Set([
78
+ "babel-plugin-react-compiler",
79
+ "react-compiler-runtime",
80
+ "eslint-plugin-react-compiler"
81
+ ]);
82
+ const NEXT_CONFIG_FILENAMES = [
83
+ "next.config.js",
84
+ "next.config.mjs",
85
+ "next.config.ts",
86
+ "next.config.cjs"
87
+ ];
88
+ const BABEL_CONFIG_FILENAMES = [
89
+ ".babelrc",
90
+ ".babelrc.json",
91
+ "babel.config.js",
92
+ "babel.config.json",
93
+ "babel.config.cjs",
94
+ "babel.config.mjs"
95
+ ];
96
+ const VITE_CONFIG_FILENAMES = [
97
+ "vite.config.js",
98
+ "vite.config.ts",
99
+ "vite.config.mjs",
100
+ "vite.config.cjs"
101
+ ];
102
+ const REACT_COMPILER_CONFIG_PATTERN = /react-compiler|reactCompiler/;
103
+ const FRAMEWORK_PACKAGES = {
104
+ next: "nextjs",
105
+ vite: "vite",
106
+ "react-scripts": "cra",
107
+ "@remix-run/react": "remix",
108
+ gatsby: "gatsby"
109
+ };
110
+ const FRAMEWORK_DISPLAY_NAMES = {
111
+ nextjs: "Next.js",
112
+ vite: "Vite",
113
+ cra: "Create React App",
114
+ remix: "Remix",
115
+ gatsby: "Gatsby",
116
+ unknown: "React"
117
+ };
118
+ const formatFrameworkName = (framework) => FRAMEWORK_DISPLAY_NAMES[framework];
119
+ const countSourceFiles = (rootDirectory) => {
120
+ const result = spawnSync("git", [
121
+ "ls-files",
122
+ "--cached",
123
+ "--others",
124
+ "--exclude-standard"
125
+ ], {
126
+ cwd: rootDirectory,
127
+ encoding: "utf-8"
128
+ });
129
+ if (result.error || result.status !== 0) return 0;
130
+ return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
131
+ };
132
+ const collectAllDependencies = (packageJson) => ({
133
+ ...packageJson.dependencies,
134
+ ...packageJson.devDependencies
135
+ });
136
+ const detectFramework = (dependencies) => {
137
+ for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
138
+ return "unknown";
139
+ };
140
+ const extractDependencyInfo = (packageJson) => {
141
+ const allDependencies = collectAllDependencies(packageJson);
142
+ return {
143
+ reactVersion: allDependencies.react ?? null,
144
+ framework: detectFramework(allDependencies)
145
+ };
146
+ };
147
+ const parsePnpmWorkspacePatterns = (rootDirectory) => {
148
+ const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
149
+ if (!fs.existsSync(workspacePath)) return [];
150
+ const content = fs.readFileSync(workspacePath, "utf-8");
151
+ const patterns = [];
152
+ let insidePackagesBlock = false;
153
+ for (const line of content.split("\n")) {
154
+ const trimmed = line.trim();
155
+ if (trimmed === "packages:") {
156
+ insidePackagesBlock = true;
157
+ continue;
158
+ }
159
+ if (insidePackagesBlock && trimmed.startsWith("-")) patterns.push(trimmed.replace(/^-\s*/, "").replace(/["']/g, ""));
160
+ else if (insidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith("#")) insidePackagesBlock = false;
161
+ }
162
+ return patterns;
163
+ };
164
+ const getWorkspacePatterns = (rootDirectory, packageJson) => {
165
+ const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory);
166
+ if (pnpmPatterns.length > 0) return pnpmPatterns;
167
+ if (Array.isArray(packageJson.workspaces)) return packageJson.workspaces;
168
+ if (packageJson.workspaces?.packages) return packageJson.workspaces.packages;
169
+ return [];
170
+ };
171
+ const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
172
+ const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
173
+ if (!cleanPattern.includes("*")) {
174
+ const directoryPath = path.join(rootDirectory, cleanPattern);
175
+ if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
176
+ return [];
177
+ }
178
+ const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, cleanPattern.indexOf("*")));
179
+ if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
180
+ return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry)).filter((entryPath) => fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
181
+ };
182
+ const findDependencyInfoFromAncestors = (startDirectory) => {
183
+ let currentDirectory = path.dirname(startDirectory);
184
+ const result = {
185
+ reactVersion: null,
186
+ framework: "unknown"
187
+ };
188
+ while (currentDirectory !== path.dirname(currentDirectory)) {
189
+ const packageJsonPath = path.join(currentDirectory, "package.json");
190
+ if (fs.existsSync(packageJsonPath)) {
191
+ const info = extractDependencyInfo(readPackageJson(packageJsonPath));
192
+ if (!result.reactVersion && info.reactVersion) result.reactVersion = info.reactVersion;
193
+ if (result.framework === "unknown" && info.framework !== "unknown") result.framework = info.framework;
194
+ if (result.reactVersion && result.framework !== "unknown") return result;
195
+ }
196
+ currentDirectory = path.dirname(currentDirectory);
197
+ }
198
+ return result;
199
+ };
200
+ const findReactInWorkspaces = (rootDirectory, packageJson) => {
201
+ const patterns = getWorkspacePatterns(rootDirectory, packageJson);
202
+ const result = {
203
+ reactVersion: null,
204
+ framework: "unknown"
205
+ };
206
+ for (const pattern of patterns) {
207
+ const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
208
+ for (const workspaceDirectory of directories) {
209
+ const info = extractDependencyInfo(readPackageJson(path.join(workspaceDirectory, "package.json")));
210
+ if (info.reactVersion && !result.reactVersion) result.reactVersion = info.reactVersion;
211
+ if (info.framework !== "unknown" && result.framework === "unknown") result.framework = info.framework;
212
+ if (result.reactVersion && result.framework !== "unknown") return result;
213
+ }
214
+ }
215
+ return result;
216
+ };
217
+ const hasReactDependency = (packageJson) => {
218
+ const allDependencies = collectAllDependencies(packageJson);
219
+ return Object.keys(allDependencies).some((packageName) => packageName === "next" || packageName.includes("react"));
220
+ };
221
+ const listWorkspacePackages = (rootDirectory) => {
222
+ const packageJsonPath = path.join(rootDirectory, "package.json");
223
+ if (!fs.existsSync(packageJsonPath)) return [];
224
+ const patterns = getWorkspacePatterns(rootDirectory, readPackageJson(packageJsonPath));
225
+ if (patterns.length === 0) return [];
226
+ const packages = [];
227
+ for (const pattern of patterns) {
228
+ const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
229
+ for (const workspaceDirectory of directories) {
230
+ const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, "package.json"));
231
+ if (!hasReactDependency(workspacePackageJson)) continue;
232
+ const name = workspacePackageJson.name ?? path.basename(workspaceDirectory);
233
+ packages.push({
234
+ name,
235
+ directory: workspaceDirectory
236
+ });
237
+ }
238
+ }
239
+ return packages;
240
+ };
241
+ const hasCompilerPackage = (packageJson) => {
242
+ const allDependencies = collectAllDependencies(packageJson);
243
+ return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
244
+ };
245
+ const fileContainsPattern = (filePath, pattern) => {
246
+ if (!fs.existsSync(filePath)) return false;
247
+ const content = fs.readFileSync(filePath, "utf-8");
248
+ return pattern.test(content);
249
+ };
250
+ const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => fileContainsPattern(path.join(directory, filename), REACT_COMPILER_CONFIG_PATTERN));
251
+ const detectReactCompiler = (directory, packageJson) => {
252
+ if (hasCompilerPackage(packageJson)) return true;
253
+ if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
254
+ if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
255
+ if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
256
+ let ancestorDirectory = path.dirname(directory);
257
+ while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
258
+ const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
259
+ if (fs.existsSync(ancestorPackagePath)) {
260
+ if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
261
+ }
262
+ ancestorDirectory = path.dirname(ancestorDirectory);
263
+ }
264
+ return false;
265
+ };
266
+ const discoverProject = (directory) => {
267
+ const packageJsonPath = path.join(directory, "package.json");
268
+ if (!fs.existsSync(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
269
+ const packageJson = readPackageJson(packageJsonPath);
270
+ let { reactVersion, framework } = extractDependencyInfo(packageJson);
271
+ if (!reactVersion || framework === "unknown") {
272
+ const workspaceInfo = findReactInWorkspaces(directory, packageJson);
273
+ if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
274
+ if (framework === "unknown" && workspaceInfo.framework !== "unknown") framework = workspaceInfo.framework;
275
+ }
276
+ if (!reactVersion || framework === "unknown") {
277
+ const ancestorInfo = findDependencyInfoFromAncestors(directory);
278
+ if (!reactVersion) reactVersion = ancestorInfo.reactVersion;
279
+ if (framework === "unknown") framework = ancestorInfo.framework;
280
+ }
281
+ const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
282
+ const sourceFileCount = countSourceFiles(directory);
283
+ const hasReactCompiler = detectReactCompiler(directory, packageJson);
284
+ return {
285
+ rootDirectory: directory,
286
+ reactVersion,
287
+ framework,
288
+ hasTypeScript,
289
+ hasReactCompiler,
290
+ sourceFileCount
291
+ };
292
+ };
293
+
294
+ //#endregion
295
+ //#region src/utils/group-by.ts
296
+ const groupBy = (items, keyFn) => {
297
+ const groups = /* @__PURE__ */ new Map();
298
+ for (const item of items) {
299
+ const key = keyFn(item);
300
+ const existing = groups.get(key) ?? [];
301
+ existing.push(item);
302
+ groups.set(key, existing);
303
+ }
304
+ return groups;
305
+ };
306
+
307
+ //#endregion
308
+ //#region src/plugin/constants.ts
309
+ const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
310
+
311
+ //#endregion
312
+ //#region src/utils/check-reduced-motion.ts
313
+ const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
314
+ const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
315
+ const checkReducedMotion = (rootDirectory) => {
316
+ const packageJsonPath = path.join(rootDirectory, "package.json");
317
+ if (!fs.existsSync(packageJsonPath)) return [];
318
+ const packageJson = readPackageJson(packageJsonPath);
319
+ const allDependencies = {
320
+ ...packageJson.dependencies,
321
+ ...packageJson.devDependencies
322
+ };
323
+ if (!Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName))) return [];
324
+ try {
325
+ execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
326
+ cwd: rootDirectory,
327
+ stdio: "pipe"
328
+ });
329
+ return [];
330
+ } catch {
331
+ return [{
332
+ filePath: "package.json",
333
+ plugin: "react-doctor",
334
+ rule: "require-reduced-motion",
335
+ severity: "error",
336
+ message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
337
+ help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
338
+ line: 0,
339
+ column: 0,
340
+ category: "Accessibility"
341
+ }];
342
+ }
343
+ };
344
+
345
+ //#endregion
346
+ //#region src/utils/run-knip.ts
347
+ const KNIP_CATEGORY_MAP = {
348
+ files: "Dead Code",
349
+ exports: "Dead Code",
350
+ types: "Dead Code",
351
+ duplicates: "Dead Code"
352
+ };
353
+ const KNIP_MESSAGE_MAP = {
354
+ files: "Unused file",
355
+ exports: "Unused export",
356
+ types: "Unused type",
357
+ duplicates: "Duplicate export"
358
+ };
359
+ const KNIP_SEVERITY_MAP = {
360
+ files: "warning",
361
+ exports: "warning",
362
+ types: "warning",
363
+ duplicates: "warning"
364
+ };
365
+ const collectIssueRecords = (records, issueType, rootDirectory) => {
366
+ const diagnostics = [];
367
+ for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
368
+ filePath: path.relative(rootDirectory, issue.filePath),
369
+ plugin: "knip",
370
+ rule: issueType,
371
+ severity: KNIP_SEVERITY_MAP[issueType] ?? "warning",
372
+ message: `${KNIP_MESSAGE_MAP[issueType]}: ${issue.symbol}`,
373
+ help: "",
374
+ line: 0,
375
+ column: 0,
376
+ category: KNIP_CATEGORY_MAP[issueType] ?? "Dead Code"
377
+ });
378
+ return diagnostics;
379
+ };
380
+ const silenced = async (fn) => {
381
+ const originalLog = console.log;
382
+ const originalInfo = console.info;
383
+ const originalWarn = console.warn;
384
+ console.log = () => {};
385
+ console.info = () => {};
386
+ console.warn = () => {};
387
+ try {
388
+ return await fn();
389
+ } finally {
390
+ console.log = originalLog;
391
+ console.info = originalInfo;
392
+ console.warn = originalWarn;
393
+ }
394
+ };
395
+ const runKnip = async (rootDirectory) => {
396
+ const options = await silenced(() => createOptions({
397
+ cwd: rootDirectory,
398
+ isShowProgress: false
399
+ }));
400
+ const { issues } = await silenced(() => main(options));
401
+ const diagnostics = [];
402
+ for (const unusedFile of issues.files) diagnostics.push({
403
+ filePath: path.relative(rootDirectory, unusedFile),
404
+ plugin: "knip",
405
+ rule: "files",
406
+ severity: KNIP_SEVERITY_MAP["files"],
407
+ message: KNIP_MESSAGE_MAP["files"],
408
+ help: "This file is not imported by any other file in the project.",
409
+ line: 0,
410
+ column: 0,
411
+ category: KNIP_CATEGORY_MAP["files"]
412
+ });
413
+ for (const issueType of [
414
+ "exports",
415
+ "types",
416
+ "duplicates"
417
+ ]) diagnostics.push(...collectIssueRecords(issues[issueType], issueType, rootDirectory));
418
+ return diagnostics;
419
+ };
420
+
421
+ //#endregion
422
+ //#region src/oxlint-config.ts
423
+ const esmRequire$1 = createRequire(import.meta.url);
424
+ const NEXTJS_RULES = {
425
+ "react-doctor/nextjs-no-img-element": "warn",
426
+ "react-doctor/nextjs-async-client-component": "error",
427
+ "react-doctor/nextjs-no-a-element": "warn",
428
+ "react-doctor/nextjs-no-use-search-params-without-suspense": "error",
429
+ "react-doctor/nextjs-no-client-fetch-for-server-data": "warn",
430
+ "react-doctor/nextjs-missing-metadata": "warn",
431
+ "react-doctor/nextjs-no-client-side-redirect": "warn",
432
+ "react-doctor/nextjs-no-css-link": "warn",
433
+ "react-doctor/nextjs-no-polyfill-script": "warn",
434
+ "react-doctor/nextjs-no-head-import": "error"
435
+ };
436
+ const REACT_COMPILER_RULES = {
437
+ "react-hooks-js/set-state-in-render": "error",
438
+ "react-hooks-js/immutability": "error",
439
+ "react-hooks-js/refs": "error",
440
+ "react-hooks-js/purity": "error",
441
+ "react-hooks-js/hooks": "error",
442
+ "react-hooks-js/set-state-in-effect": "error",
443
+ "react-hooks-js/globals": "error",
444
+ "react-hooks-js/error-boundaries": "error",
445
+ "react-hooks-js/preserve-manual-memoization": "error",
446
+ "react-hooks-js/unsupported-syntax": "error",
447
+ "react-hooks-js/component-hook-factories": "error",
448
+ "react-hooks-js/static-components": "error",
449
+ "react-hooks-js/use-memo": "error",
450
+ "react-hooks-js/void-use-memo": "error",
451
+ "react-hooks-js/incompatible-library": "error",
452
+ "react-hooks-js/todo": "error"
453
+ };
454
+ const REACT_PERF_RULES = {
455
+ "react-perf/jsx-no-new-object-as-prop": "warn",
456
+ "react-perf/jsx-no-new-array-as-prop": "warn",
457
+ "react-perf/jsx-no-new-function-as-prop": "warn",
458
+ "react-perf/jsx-no-jsx-as-prop": "warn"
459
+ };
460
+ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
461
+ categories: {
462
+ correctness: "off",
463
+ suspicious: "off",
464
+ pedantic: "off",
465
+ perf: "off",
466
+ restriction: "off",
467
+ style: "off",
468
+ nursery: "off"
469
+ },
470
+ plugins: [
471
+ "react",
472
+ "jsx-a11y",
473
+ ...hasReactCompiler ? [] : ["react-perf"]
474
+ ],
475
+ jsPlugins: [...hasReactCompiler ? [{
476
+ name: "react-hooks-js",
477
+ specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
478
+ }] : [], pluginPath],
479
+ rules: {
480
+ "react/rules-of-hooks": "error",
481
+ "react/no-direct-mutation-state": "error",
482
+ "react/jsx-no-duplicate-props": "error",
483
+ "react/jsx-key": "error",
484
+ "react/no-children-prop": "warn",
485
+ "react/no-danger": "warn",
486
+ "react/jsx-no-script-url": "error",
487
+ "react/no-render-return-value": "warn",
488
+ "react/no-string-refs": "warn",
489
+ "react/no-unescaped-entities": "warn",
490
+ "react/no-is-mounted": "warn",
491
+ "react/require-render-return": "error",
492
+ "react/no-unknown-property": "warn",
493
+ "jsx-a11y/alt-text": "error",
494
+ "jsx-a11y/anchor-is-valid": "warn",
495
+ "jsx-a11y/click-events-have-key-events": "warn",
496
+ "jsx-a11y/no-static-element-interactions": "warn",
497
+ "jsx-a11y/no-noninteractive-element-interactions": "warn",
498
+ "jsx-a11y/role-has-required-aria-props": "error",
499
+ "jsx-a11y/no-autofocus": "warn",
500
+ "jsx-a11y/heading-has-content": "warn",
501
+ "jsx-a11y/html-has-lang": "warn",
502
+ "jsx-a11y/no-redundant-roles": "warn",
503
+ "jsx-a11y/scope": "warn",
504
+ "jsx-a11y/tabindex-no-positive": "warn",
505
+ "jsx-a11y/label-has-associated-control": "warn",
506
+ "jsx-a11y/no-distracting-elements": "error",
507
+ "jsx-a11y/iframe-has-title": "warn",
508
+ ...hasReactCompiler ? REACT_COMPILER_RULES : REACT_PERF_RULES,
509
+ "react-doctor/no-derived-state-effect": "error",
510
+ "react-doctor/no-fetch-in-effect": "error",
511
+ "react-doctor/no-cascading-set-state": "warn",
512
+ "react-doctor/no-effect-event-handler": "warn",
513
+ "react-doctor/no-derived-useState": "warn",
514
+ "react-doctor/prefer-useReducer": "warn",
515
+ "react-doctor/rerender-lazy-state-init": "warn",
516
+ "react-doctor/rerender-functional-setstate": "warn",
517
+ "react-doctor/rerender-dependencies": "error",
518
+ "react-doctor/no-generic-handler-names": "warn",
519
+ "react-doctor/no-giant-component": "warn",
520
+ "react-doctor/no-render-in-render": "warn",
521
+ "react-doctor/no-nested-component-definition": "error",
522
+ "react-doctor/no-usememo-simple-expression": "warn",
523
+ "react-doctor/no-layout-property-animation": "error",
524
+ "react-doctor/rerender-memo-with-default-value": "warn",
525
+ "react-doctor/rendering-animate-svg-wrapper": "warn",
526
+ "react-doctor/rendering-usetransition-loading": "warn",
527
+ "react-doctor/rendering-hydration-no-flicker": "warn",
528
+ "react-doctor/no-transition-all": "warn",
529
+ "react-doctor/no-global-css-variable-animation": "error",
530
+ "react-doctor/no-large-animated-blur": "warn",
531
+ "react-doctor/no-scale-from-zero": "warn",
532
+ "react-doctor/no-permanent-will-change": "warn",
533
+ "react-doctor/no-secrets-in-client-code": "error",
534
+ "react-doctor/no-barrel-import": "warn",
535
+ "react-doctor/no-full-lodash-import": "warn",
536
+ "react-doctor/no-moment": "warn",
537
+ "react-doctor/prefer-dynamic-import": "warn",
538
+ "react-doctor/use-lazy-motion": "warn",
539
+ "react-doctor/no-undeferred-third-party": "warn",
540
+ "react-doctor/no-array-index-as-key": "warn",
541
+ "react-doctor/rendering-conditional-render": "warn",
542
+ "react-doctor/no-prevent-default": "warn",
543
+ "react-doctor/server-auth-actions": "error",
544
+ "react-doctor/server-after-nonblocking": "warn",
545
+ "react-doctor/client-passive-event-listeners": "warn",
546
+ "react-doctor/async-parallel": "warn",
547
+ ...framework === "nextjs" ? NEXTJS_RULES : {}
548
+ }
549
+ });
550
+
551
+ //#endregion
552
+ //#region src/utils/run-oxlint.ts
553
+ const esmRequire = createRequire(import.meta.url);
554
+ const PLUGIN_CATEGORY_MAP = {
555
+ react: "Correctness",
556
+ "react-hooks": "Correctness",
557
+ "react-hooks-js": "React Compiler",
558
+ "react-perf": "Performance",
559
+ "jsx-a11y": "Accessibility"
560
+ };
561
+ const RULE_CATEGORY_MAP = {
562
+ "react-doctor/no-derived-state-effect": "State & Effects",
563
+ "react-doctor/no-fetch-in-effect": "State & Effects",
564
+ "react-doctor/no-cascading-set-state": "State & Effects",
565
+ "react-doctor/no-effect-event-handler": "State & Effects",
566
+ "react-doctor/no-derived-useState": "State & Effects",
567
+ "react-doctor/prefer-useReducer": "State & Effects",
568
+ "react-doctor/rerender-lazy-state-init": "Performance",
569
+ "react-doctor/rerender-functional-setstate": "Performance",
570
+ "react-doctor/rerender-dependencies": "State & Effects",
571
+ "react-doctor/no-generic-handler-names": "Architecture",
572
+ "react-doctor/no-giant-component": "Architecture",
573
+ "react-doctor/no-render-in-render": "Architecture",
574
+ "react-doctor/no-nested-component-definition": "Correctness",
575
+ "react-doctor/no-usememo-simple-expression": "Performance",
576
+ "react-doctor/no-layout-property-animation": "Performance",
577
+ "react-doctor/rerender-memo-with-default-value": "Performance",
578
+ "react-doctor/rendering-animate-svg-wrapper": "Performance",
579
+ "react-doctor/rendering-usetransition-loading": "Performance",
580
+ "react-doctor/rendering-hydration-no-flicker": "Performance",
581
+ "react-doctor/no-transition-all": "Performance",
582
+ "react-doctor/no-global-css-variable-animation": "Performance",
583
+ "react-doctor/no-large-animated-blur": "Performance",
584
+ "react-doctor/no-scale-from-zero": "Performance",
585
+ "react-doctor/no-permanent-will-change": "Performance",
586
+ "react-doctor/no-secrets-in-client-code": "Security",
587
+ "react-doctor/no-barrel-import": "Bundle Size",
588
+ "react-doctor/no-full-lodash-import": "Bundle Size",
589
+ "react-doctor/no-moment": "Bundle Size",
590
+ "react-doctor/prefer-dynamic-import": "Bundle Size",
591
+ "react-doctor/use-lazy-motion": "Bundle Size",
592
+ "react-doctor/no-undeferred-third-party": "Bundle Size",
593
+ "react-doctor/no-array-index-as-key": "Correctness",
594
+ "react-doctor/rendering-conditional-render": "Correctness",
595
+ "react-doctor/no-prevent-default": "Correctness",
596
+ "react-doctor/nextjs-no-img-element": "Next.js",
597
+ "react-doctor/nextjs-async-client-component": "Next.js",
598
+ "react-doctor/nextjs-no-a-element": "Next.js",
599
+ "react-doctor/nextjs-no-use-search-params-without-suspense": "Next.js",
600
+ "react-doctor/nextjs-no-client-fetch-for-server-data": "Next.js",
601
+ "react-doctor/nextjs-missing-metadata": "Next.js",
602
+ "react-doctor/nextjs-no-client-side-redirect": "Next.js",
603
+ "react-doctor/nextjs-no-redirect-in-try-catch": "Next.js",
604
+ "react-doctor/nextjs-image-missing-sizes": "Next.js",
605
+ "react-doctor/nextjs-no-native-script": "Next.js",
606
+ "react-doctor/nextjs-inline-script-missing-id": "Next.js",
607
+ "react-doctor/nextjs-no-font-link": "Next.js",
608
+ "react-doctor/nextjs-no-css-link": "Next.js",
609
+ "react-doctor/nextjs-no-polyfill-script": "Next.js",
610
+ "react-doctor/nextjs-no-head-import": "Next.js",
611
+ "react-doctor/server-auth-actions": "Server",
612
+ "react-doctor/server-after-nonblocking": "Server",
613
+ "react-doctor/client-passive-event-listeners": "Performance",
614
+ "react-doctor/async-parallel": "Performance"
615
+ };
616
+ const RULE_HELP_MAP = {
617
+ "no-derived-state-effect": "Compute during render: `const derived = computeFrom(dep1, dep2)` — no useEffect needed",
618
+ "no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
619
+ "no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
620
+ "no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
621
+ "no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
622
+ "prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
623
+ "rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
624
+ "rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
625
+ "rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
626
+ "no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
627
+ "no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
628
+ "no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
629
+ "no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
630
+ "no-usememo-simple-expression": "Remove useMemo — property access, math, and ternaries are already cheap without memoization",
631
+ "no-layout-property-animation": "Use `transform: translateX()` or `scale()` instead — they run on the compositor and skip layout/paint",
632
+ "rerender-memo-with-default-value": "Move to module scope: `const EMPTY_ITEMS: Item[] = []` then use as the default value",
633
+ "rendering-animate-svg-wrapper": "Wrap the SVG: `<motion.div animate={...}><svg>...</svg></motion.div>`",
634
+ "rendering-usetransition-loading": "Replace with `const [isPending, startTransition] = useTransition()` — avoids a re-render for the loading state",
635
+ "rendering-hydration-no-flicker": "Use `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)` or add `suppressHydrationWarning` to the element",
636
+ "no-transition-all": "List specific properties: `transition: \"opacity 200ms, transform 200ms\"` — or in Tailwind use `transition-colors`, `transition-opacity`, or `transition-transform`",
637
+ "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",
638
+ "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",
639
+ "no-scale-from-zero": "Use `initial={{ scale: 0.95, opacity: 0 }}` — elements should deflate like a balloon, not vanish into a point",
640
+ "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",
641
+ "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)",
642
+ "no-barrel-import": "Import from the direct path: `import { Button } from './components/Button'` instead of `./components`",
643
+ "no-full-lodash-import": "Import the specific function: `import debounce from 'lodash/debounce'` — saves ~70kb",
644
+ "no-moment": "Replace with `import { format } from 'date-fns'` (tree-shakeable) or `import dayjs from 'dayjs'` (2kb)",
645
+ "prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
646
+ "use-lazy-motion": "Use `import { LazyMotion, m } from \"framer-motion\"` with `domAnimation` features — saves ~30kb",
647
+ "no-undeferred-third-party": "Use `next/script` with `strategy=\"lazyOnload\"` or add the `defer` attribute",
648
+ "no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
649
+ "rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
650
+ "no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
651
+ "nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
652
+ "nextjs-async-client-component": "Fetch data in a parent Server Component and pass it as props, or use useQuery/useSWR in the client component",
653
+ "nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
654
+ "nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
655
+ "nextjs-no-client-fetch-for-server-data": "Remove 'use client' and fetch directly in the Server Component — no API round-trip, secrets stay on server",
656
+ "nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
657
+ "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' in a Server Component, or handle in middleware",
658
+ "nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
659
+ "nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
660
+ "nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
661
+ "nextjs-inline-script-missing-id": "Add `id=\"descriptive-name\"` so Next.js can track, deduplicate, and re-execute the script correctly",
662
+ "nextjs-no-font-link": "`import { Inter } from \"next/font/google\"` — self-hosted, zero layout shift, no render-blocking requests",
663
+ "nextjs-no-css-link": "Import CSS directly: `import './styles.css'` or use CSS Modules: `import styles from './Button.module.css'`",
664
+ "nextjs-no-polyfill-script": "Next.js includes polyfills for fetch, Promise, Object.assign, Array.from, and 50+ others automatically",
665
+ "nextjs-no-head-import": "Use the Metadata API instead: `export const metadata = { title: '...' }` or `export async function generateMetadata()`",
666
+ "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
667
+ "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
668
+ "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
669
+ "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently"
670
+ };
671
+ const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
672
+ const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
673
+ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
674
+ if (plugin === "react-hooks-js") return {
675
+ message: REACT_COMPILER_MESSAGE,
676
+ help: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help
677
+ };
678
+ return {
679
+ message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
680
+ help: help || RULE_HELP_MAP[rule] || ""
681
+ };
682
+ };
683
+ const parseRuleCode = (code) => {
684
+ const match = code.match(/^(.+)\((.+)\)$/);
685
+ if (!match) return {
686
+ plugin: "unknown",
687
+ rule: code
688
+ };
689
+ return {
690
+ plugin: match[1].replace(/^eslint-plugin-/, ""),
691
+ rule: match[2]
692
+ };
693
+ };
694
+ const resolveOxlintBinary = () => {
695
+ const oxlintMainPath = esmRequire.resolve("oxlint");
696
+ const oxlintPackageDirectory = path.resolve(path.dirname(oxlintMainPath), "..");
697
+ return path.join(oxlintPackageDirectory, "bin", "oxlint");
698
+ };
699
+ const resolvePluginPath = () => {
700
+ const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
701
+ return path.join(currentDirectory, "react-doctor-plugin.js");
702
+ };
703
+ const resolveDiagnosticCategory = (plugin, rule) => {
704
+ return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
705
+ };
706
+ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler) => {
707
+ const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
708
+ const config = createOxlintConfig({
709
+ pluginPath: resolvePluginPath(),
710
+ framework,
711
+ hasReactCompiler
712
+ });
713
+ try {
714
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
715
+ const args = [
716
+ resolveOxlintBinary(),
717
+ "-c",
718
+ configPath,
719
+ "--format",
720
+ "json"
721
+ ];
722
+ if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
723
+ args.push(".");
724
+ const stdout = await new Promise((resolve, reject) => {
725
+ const child = spawn(process.execPath, args, { cwd: rootDirectory });
726
+ const stdoutBuffers = [];
727
+ const stderrBuffers = [];
728
+ child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
729
+ child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
730
+ child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
731
+ child.on("close", () => {
732
+ const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
733
+ if (!output) {
734
+ const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
735
+ if (stderrOutput) {
736
+ reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
737
+ return;
738
+ }
739
+ }
740
+ resolve(output);
741
+ });
742
+ });
743
+ if (!stdout) return [];
744
+ let output;
745
+ try {
746
+ output = JSON.parse(stdout);
747
+ } catch {
748
+ throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
749
+ }
750
+ return output.diagnostics.filter((diagnostic) => JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
751
+ const { plugin, rule } = parseRuleCode(diagnostic.code);
752
+ const primaryLabel = diagnostic.labels[0];
753
+ const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
754
+ return {
755
+ filePath: diagnostic.filename,
756
+ plugin,
757
+ rule,
758
+ severity: diagnostic.severity,
759
+ message: cleaned.message,
760
+ help: cleaned.help,
761
+ line: primaryLabel?.span.line ?? 0,
762
+ column: primaryLabel?.span.column ?? 0,
763
+ category: resolveDiagnosticCategory(plugin, rule)
764
+ };
765
+ });
766
+ } finally {
767
+ if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
768
+ }
769
+ };
770
+
771
+ //#endregion
772
+ //#region src/utils/spinner.ts
773
+ const spinner = (text) => ora({ text });
774
+
775
+ //#endregion
776
+ //#region src/scan.ts
777
+ const SEVERITY_ORDER = {
778
+ error: 0,
779
+ warning: 1
780
+ };
781
+ const sortBySeverity = (diagnosticGroups) => diagnosticGroups.toSorted(([, diagnosticsA], [, diagnosticsB]) => {
782
+ return SEVERITY_ORDER[diagnosticsA[0].severity] - SEVERITY_ORDER[diagnosticsB[0].severity];
783
+ });
784
+ const collectAffectedFiles = (diagnostics) => {
785
+ const files = /* @__PURE__ */ new Set();
786
+ for (const diagnostic of diagnostics) files.add(diagnostic.filePath);
787
+ return files;
788
+ };
789
+ const printDiagnostics = (diagnostics) => {
790
+ const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
791
+ for (const [, ruleDiagnostics] of sortedRuleGroups) {
792
+ const firstDiagnostic = ruleDiagnostics[0];
793
+ const icon = firstDiagnostic.severity === "error" ? highlighter.error("✗") : highlighter.warn("⚠");
794
+ const count = ruleDiagnostics.length;
795
+ const countLabel = count > 1 ? ` (${count})` : "";
796
+ logger.log(` ${icon} ${firstDiagnostic.message}${countLabel}`);
797
+ if (firstDiagnostic.help) logger.dim(` ${firstDiagnostic.help}`);
798
+ const fileLines = /* @__PURE__ */ new Map();
799
+ for (const diagnostic of ruleDiagnostics) {
800
+ const lines = fileLines.get(diagnostic.filePath) ?? [];
801
+ if (diagnostic.line > 0) lines.push(diagnostic.line);
802
+ fileLines.set(diagnostic.filePath, lines);
803
+ }
804
+ for (const [filePath, lines] of fileLines) {
805
+ const lineLabel = lines.length > 0 ? `: ${lines.join(", ")}` : "";
806
+ logger.dim(` ${filePath}${lineLabel}`);
807
+ }
808
+ logger.break();
809
+ }
810
+ };
811
+ const formatElapsedTime = (elapsedMilliseconds) => {
812
+ if (elapsedMilliseconds < 1e3) return `${Math.round(elapsedMilliseconds)}ms`;
813
+ return `${(elapsedMilliseconds / 1e3).toFixed(1)}s`;
814
+ };
815
+ const formatRuleSummary = (ruleKey, ruleDiagnostics) => {
816
+ const firstDiagnostic = ruleDiagnostics[0];
817
+ const fileLines = /* @__PURE__ */ new Map();
818
+ for (const diagnostic of ruleDiagnostics) {
819
+ const lines = fileLines.get(diagnostic.filePath) ?? [];
820
+ if (diagnostic.line > 0) lines.push(diagnostic.line);
821
+ fileLines.set(diagnostic.filePath, lines);
822
+ }
823
+ const sections = [
824
+ `Rule: ${ruleKey}`,
825
+ `Severity: ${firstDiagnostic.severity}`,
826
+ `Category: ${firstDiagnostic.category}`,
827
+ `Count: ${ruleDiagnostics.length}`,
828
+ "",
829
+ firstDiagnostic.message
830
+ ];
831
+ if (firstDiagnostic.help) sections.push("", `Suggestion: ${firstDiagnostic.help}`);
832
+ sections.push("", "Files:");
833
+ for (const [filePath, lines] of fileLines) {
834
+ const lineLabel = lines.length > 0 ? `: ${lines.join(", ")}` : "";
835
+ sections.push(` ${filePath}${lineLabel}`);
836
+ }
837
+ return sections.join("\n") + "\n";
838
+ };
839
+ const writeDiagnosticsDirectory = (diagnostics) => {
840
+ const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
841
+ mkdirSync(outputDirectory);
842
+ const sortedRuleGroups = sortBySeverity([...groupBy(diagnostics, (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`).entries()]);
843
+ for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) writeFileSync(join(outputDirectory, ruleKey.replace(/\//g, "--") + ".txt"), formatRuleSummary(ruleKey, ruleDiagnostics));
844
+ writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));
845
+ return outputDirectory;
846
+ };
847
+ const printSummary = (diagnostics, elapsedMilliseconds) => {
848
+ const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
849
+ const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
850
+ const affectedFileCount = collectAffectedFiles(diagnostics).size;
851
+ const elapsed = formatElapsedTime(elapsedMilliseconds);
852
+ logger.log("─".repeat(SEPARATOR_LENGTH_CHARS));
853
+ logger.break();
854
+ const parts = [];
855
+ if (errorCount > 0) parts.push(highlighter.error(`${errorCount} error${errorCount === 1 ? "" : "s"}`));
856
+ if (warningCount > 0) parts.push(highlighter.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"}`));
857
+ parts.push(highlighter.dim(`across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`));
858
+ parts.push(highlighter.dim(`in ${elapsed}`));
859
+ logger.log(parts.join(" "));
860
+ const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
861
+ logger.break();
862
+ logger.dim(`Full diagnostics written to ${diagnosticsDirectory}`);
863
+ };
864
+ const scan = async (directory, options) => {
865
+ const startTime = performance.now();
866
+ const projectInfo = discoverProject(directory);
867
+ if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
868
+ const frameworkLabel = formatFrameworkName(projectInfo.framework);
869
+ const languageLabel = projectInfo.hasTypeScript ? "TypeScript" : "JavaScript";
870
+ const completeStep = (message) => {
871
+ spinner(message).start().succeed(message);
872
+ };
873
+ completeStep(`Detecting framework. Found ${highlighter.info(frameworkLabel)}.`);
874
+ completeStep(`Detecting React version. Found ${highlighter.info(`React ${projectInfo.reactVersion}`)}.`);
875
+ completeStep(`Detecting language. Found ${highlighter.info(languageLabel)}.`);
876
+ completeStep(`Detecting React Compiler. ${projectInfo.hasReactCompiler ? highlighter.info("Found React Compiler.") : "Not found."}`);
877
+ completeStep(`Found ${highlighter.info(`${projectInfo.sourceFileCount}`)} source files.`);
878
+ logger.break();
879
+ const diagnostics = [];
880
+ if (options.lint) {
881
+ const lintSpinner = spinner("Running lint checks...").start();
882
+ diagnostics.push(...await runOxlint(directory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler));
883
+ lintSpinner.succeed("Running lint checks.");
884
+ }
885
+ if (options.deadCode) {
886
+ const deadCodeSpinner = spinner("Detecting dead code...").start();
887
+ diagnostics.push(...await runKnip(directory));
888
+ deadCodeSpinner.succeed("Detecting dead code.");
889
+ }
890
+ diagnostics.push(...checkReducedMotion(directory));
891
+ const elapsedMilliseconds = performance.now() - startTime;
892
+ if (diagnostics.length === 0) {
893
+ logger.success("No issues found!");
894
+ return;
895
+ }
896
+ printDiagnostics(diagnostics);
897
+ printSummary(diagnostics, elapsedMilliseconds);
898
+ };
899
+
900
+ //#endregion
901
+ //#region src/utils/prompts.ts
902
+ const onCancel = () => {
903
+ logger.break();
904
+ logger.log("Cancelled.");
905
+ logger.break();
906
+ process.exit(0);
907
+ };
908
+ const prompts = (questions) => {
909
+ return basePrompts(questions, { onCancel });
910
+ };
911
+
912
+ //#endregion
913
+ //#region src/utils/select-projects.ts
914
+ const selectProjects = async (rootDirectory, projectFlag) => {
915
+ const workspacePackages = listWorkspacePackages(rootDirectory);
916
+ if (workspacePackages.length === 0) return [rootDirectory];
917
+ if (projectFlag) return resolveProjectFlag(projectFlag, workspacePackages);
918
+ return promptProjectSelection(workspacePackages, rootDirectory);
919
+ };
920
+ const resolveProjectFlag = (projectFlag, workspacePackages) => {
921
+ const requestedNames = projectFlag.split(",").map((name) => name.trim());
922
+ const resolvedDirectories = [];
923
+ for (const requestedName of requestedNames) {
924
+ const matched = workspacePackages.find((workspacePackage) => workspacePackage.name === requestedName || path.basename(workspacePackage.directory) === requestedName);
925
+ if (!matched) {
926
+ const availableNames = workspacePackages.map((workspacePackage) => workspacePackage.name).join(", ");
927
+ throw new Error(`Project "${requestedName}" not found. Available: ${availableNames}`);
928
+ }
929
+ resolvedDirectories.push(matched.directory);
930
+ }
931
+ return resolvedDirectories;
932
+ };
933
+ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
934
+ const { selectedDirectories } = await prompts({
935
+ type: "multiselect",
936
+ name: "selectedDirectories",
937
+ message: "Select projects to scan",
938
+ choices: workspacePackages.map((workspacePackage) => ({
939
+ title: workspacePackage.name,
940
+ description: path.relative(rootDirectory, workspacePackage.directory),
941
+ value: workspacePackage.directory
942
+ })),
943
+ min: 1
944
+ });
945
+ return selectedDirectories;
946
+ };
947
+
948
+ //#endregion
949
+ //#region src/cli.ts
950
+ const VERSION = "0.0.4";
951
+ process.on("SIGINT", () => process.exit(0));
952
+ process.on("SIGTERM", () => process.exit(0));
953
+ const program = new Command().name("react-doctor").description("Diagnose React codebase health").version(VERSION, "-v, --version", "display the version number").argument("[directory]", "project directory to scan", ".").option("--no-lint", "skip linting").option("--no-dead-code", "skip dead code detection").option("--project <name>", "select workspace project (comma-separated for multiple)").action(async (directory, flags) => {
954
+ try {
955
+ const resolvedDirectory = path.resolve(directory);
956
+ logger.log(`react-doctor v${VERSION}`);
957
+ logger.break();
958
+ const scanOptions = {
959
+ lint: flags.lint,
960
+ deadCode: flags.deadCode
961
+ };
962
+ const projectDirectories = await selectProjects(resolvedDirectory, flags.project);
963
+ for (const projectDirectory of projectDirectories) {
964
+ logger.dim(`Scanning ${projectDirectory}...`);
965
+ logger.break();
966
+ await scan(projectDirectory, scanOptions);
967
+ logger.break();
968
+ }
969
+ } catch (error) {
970
+ handleError(error);
971
+ }
972
+ }).addHelpText("after", `
973
+ ${highlighter.dim("Learn more:")}
974
+ ${highlighter.info("https://github.com/aidenybai/react-doctor")}
975
+ `);
976
+ const main$1 = async () => {
977
+ await program.parseAsync();
978
+ };
979
+ main$1();
980
+
981
+ //#endregion
982
+ export { };
983
+ //# sourceMappingURL=cli.js.map