react-doctor 0.0.34 → 0.0.36

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,281 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import fs, { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
3
+ import fs, { accessSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import os, { tmpdir } from "node:os";
5
5
  import path, { join } from "node:path";
6
6
  import { Command } from "commander";
7
+ import { fileURLToPath } from "node:url";
8
+ import pc from "picocolors";
9
+ import basePrompts from "prompts";
10
+ import ora from "ora";
7
11
  import { randomUUID } from "node:crypto";
8
12
  import { performance } from "node:perf_hooks";
9
13
  import { execSync, spawn, spawnSync } from "node:child_process";
10
- import pc from "picocolors";
11
- import basePrompts from "prompts";
12
14
  import { main } from "knip";
13
15
  import { createOptions } from "knip/session";
14
- import { fileURLToPath } from "node:url";
15
- import ora from "ora";
16
16
 
17
+ //#region src/utils/detect-agents.ts
18
+ const SUPPORTED_AGENTS = {
19
+ claude: {
20
+ binaries: ["claude"],
21
+ displayName: "Claude Code",
22
+ skillDir: ".claude/skills"
23
+ },
24
+ codex: {
25
+ binaries: ["codex"],
26
+ displayName: "Codex",
27
+ skillDir: ".codex/skills"
28
+ },
29
+ copilot: {
30
+ binaries: ["copilot"],
31
+ displayName: "GitHub Copilot",
32
+ skillDir: ".github/copilot/skills"
33
+ },
34
+ gemini: {
35
+ binaries: ["gemini"],
36
+ displayName: "Gemini CLI",
37
+ skillDir: ".gemini/skills"
38
+ },
39
+ cursor: {
40
+ binaries: ["cursor", "agent"],
41
+ displayName: "Cursor",
42
+ skillDir: ".cursor/skills"
43
+ },
44
+ opencode: {
45
+ binaries: ["opencode"],
46
+ displayName: "OpenCode",
47
+ skillDir: ".opencode/skills"
48
+ },
49
+ droid: {
50
+ binaries: ["droid"],
51
+ displayName: "Factory Droid",
52
+ skillDir: ".droid/skills"
53
+ },
54
+ pi: {
55
+ binaries: ["pi", "omegon"],
56
+ displayName: "Pi",
57
+ skillDir: ".pi/skills"
58
+ }
59
+ };
60
+ const ALL_SUPPORTED_AGENTS = Object.keys(SUPPORTED_AGENTS);
61
+ const isCommandAvailable = (command) => {
62
+ const pathDirectories = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
63
+ for (const directory of pathDirectories) {
64
+ const binaryPath = path.join(directory, command);
65
+ try {
66
+ if (statSync(binaryPath).isFile()) {
67
+ accessSync(binaryPath, constants.X_OK);
68
+ return true;
69
+ }
70
+ } catch {}
71
+ }
72
+ return false;
73
+ };
74
+ const detectAvailableAgents = () => ALL_SUPPORTED_AGENTS.filter((agent) => SUPPORTED_AGENTS[agent].binaries.some(isCommandAvailable));
75
+ const toDisplayName = (agent) => SUPPORTED_AGENTS[agent].displayName;
76
+ const toSkillDir = (agent) => SUPPORTED_AGENTS[agent].skillDir;
77
+
78
+ //#endregion
79
+ //#region src/utils/highlighter.ts
80
+ const highlighter = {
81
+ error: pc.red,
82
+ warn: pc.yellow,
83
+ info: pc.cyan,
84
+ success: pc.green,
85
+ dim: pc.dim
86
+ };
87
+
88
+ //#endregion
89
+ //#region src/utils/install-skill-for-agent.ts
90
+ const installSkillForAgent = (projectRoot, agent, skillSourceDirectory, skillName) => {
91
+ const installedSkillDirectory = path.join(projectRoot, toSkillDir(agent), skillName);
92
+ rmSync(installedSkillDirectory, {
93
+ recursive: true,
94
+ force: true
95
+ });
96
+ mkdirSync(path.dirname(installedSkillDirectory), { recursive: true });
97
+ cpSync(skillSourceDirectory, installedSkillDirectory, { recursive: true });
98
+ return installedSkillDirectory;
99
+ };
100
+
101
+ //#endregion
102
+ //#region src/utils/logger.ts
103
+ const logger = {
104
+ error(...args) {
105
+ console.log(highlighter.error(args.join(" ")));
106
+ },
107
+ warn(...args) {
108
+ console.log(highlighter.warn(args.join(" ")));
109
+ },
110
+ info(...args) {
111
+ console.log(highlighter.info(args.join(" ")));
112
+ },
113
+ success(...args) {
114
+ console.log(highlighter.success(args.join(" ")));
115
+ },
116
+ dim(...args) {
117
+ console.log(highlighter.dim(args.join(" ")));
118
+ },
119
+ log(...args) {
120
+ console.log(args.join(" "));
121
+ },
122
+ break() {
123
+ console.log("");
124
+ }
125
+ };
126
+
127
+ //#endregion
128
+ //#region src/utils/should-auto-select-current-choice.ts
129
+ const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
130
+ if (choiceStates.some((choiceState) => choiceState.selected)) return false;
131
+ const currentChoice = choiceStates[cursor];
132
+ return Boolean(currentChoice) && !currentChoice.disabled;
133
+ };
134
+
135
+ //#endregion
136
+ //#region src/utils/should-select-all-choices.ts
137
+ const shouldSelectAllChoices = (choiceStates) => {
138
+ return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
139
+ };
140
+
141
+ //#endregion
142
+ //#region src/utils/prompts.ts
143
+ const require = createRequire(import.meta.url);
144
+ const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
145
+ const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
146
+ let didPatchMultiselectToggleAll = false;
147
+ let didPatchMultiselectSubmit = false;
148
+ let didPatchSelectBanner = false;
149
+ const selectBannerMap = /* @__PURE__ */ new Map();
150
+ const onCancel = () => {
151
+ logger.break();
152
+ logger.log("Cancelled.");
153
+ logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
154
+ logger.break();
155
+ process.exit(0);
156
+ };
157
+ const patchMultiselectToggleAll = () => {
158
+ if (didPatchMultiselectToggleAll) return;
159
+ didPatchMultiselectToggleAll = true;
160
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
161
+ multiselectPromptConstructor.prototype.toggleAll = function() {
162
+ const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
163
+ if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
164
+ this.bell();
165
+ return;
166
+ }
167
+ const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
168
+ for (const choiceState of this.value) {
169
+ if (choiceState.disabled) continue;
170
+ choiceState.selected = shouldSelectAllEnabledChoices;
171
+ }
172
+ this.render();
173
+ };
174
+ };
175
+ const patchMultiselectSubmit = () => {
176
+ if (didPatchMultiselectSubmit) return;
177
+ didPatchMultiselectSubmit = true;
178
+ const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
179
+ const originalSubmit = multiselectPromptConstructor.prototype.submit;
180
+ multiselectPromptConstructor.prototype.submit = function() {
181
+ if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
182
+ originalSubmit.call(this);
183
+ };
184
+ };
185
+ const patchSelectBanner = () => {
186
+ if (didPatchSelectBanner) return;
187
+ didPatchSelectBanner = true;
188
+ const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
189
+ const promptsClear = require("prompts/lib/util/clear");
190
+ const originalRender = selectConstructor.prototype.render;
191
+ selectConstructor.prototype.render = function() {
192
+ originalRender.call(this);
193
+ const banner = selectBannerMap.get(this.cursor);
194
+ if (!banner || this.closed || this.done) return;
195
+ this.out.write(promptsClear(this.outputText, this.out.columns));
196
+ this.outputText = `${banner}\n\n${this.outputText}`;
197
+ this.out.write(this.outputText);
198
+ };
199
+ };
200
+ const prompts = (questions) => {
201
+ patchMultiselectToggleAll();
202
+ patchMultiselectSubmit();
203
+ patchSelectBanner();
204
+ return basePrompts(questions, { onCancel });
205
+ };
206
+
207
+ //#endregion
208
+ //#region src/utils/spinner.ts
209
+ let sharedInstance = null;
210
+ let activeCount = 0;
211
+ const pendingTexts = /* @__PURE__ */ new Set();
212
+ const finalize = (method, originalText, displayText) => {
213
+ pendingTexts.delete(originalText);
214
+ activeCount--;
215
+ if (activeCount <= 0 || !sharedInstance) {
216
+ sharedInstance?.[method](displayText);
217
+ sharedInstance = null;
218
+ activeCount = 0;
219
+ return;
220
+ }
221
+ sharedInstance.stop();
222
+ ora(displayText).start()[method](displayText);
223
+ const [remainingText] = pendingTexts;
224
+ if (remainingText) sharedInstance.text = remainingText;
225
+ sharedInstance.start();
226
+ };
227
+ const spinner = (text) => ({ start() {
228
+ activeCount++;
229
+ pendingTexts.add(text);
230
+ if (!sharedInstance) sharedInstance = ora({ text }).start();
231
+ else sharedInstance.text = text;
232
+ return {
233
+ succeed: (displayText) => finalize("succeed", text, displayText),
234
+ fail: (displayText) => finalize("fail", text, displayText)
235
+ };
236
+ } });
237
+
238
+ //#endregion
239
+ //#region src/install-skill.ts
240
+ const SKILL_NAME = "react-doctor";
241
+ const getSkillSourceDirectory = () => {
242
+ const distDirectory = path.dirname(fileURLToPath(import.meta.url));
243
+ return path.join(distDirectory, "skills", SKILL_NAME);
244
+ };
245
+ const runInstallSkill = async (options = {}) => {
246
+ const projectRoot = process.cwd();
247
+ const sourceDir = getSkillSourceDirectory();
248
+ if (!existsSync(path.join(sourceDir, "SKILL.md"))) {
249
+ logger.error(`Could not locate the ${SKILL_NAME} skill bundled with this package.`);
250
+ process.exitCode = 1;
251
+ return;
252
+ }
253
+ const detectedAgents = detectAvailableAgents();
254
+ if (detectedAgents.length === 0) {
255
+ logger.error("No supported coding agents detected on your PATH.");
256
+ logger.dim(" Supported: Claude Code, Codex, GitHub Copilot, Gemini CLI, Cursor, OpenCode, Factory Droid, Pi.");
257
+ process.exitCode = 1;
258
+ return;
259
+ }
260
+ const selectedAgents = Boolean(options.yes) || !process.stdin.isTTY ? detectedAgents : (await prompts({
261
+ type: "multiselect",
262
+ name: "agents",
263
+ message: `Install the ${highlighter.info(SKILL_NAME)} skill for:`,
264
+ choices: detectedAgents.map((agent) => ({
265
+ title: toDisplayName(agent),
266
+ value: agent,
267
+ selected: true
268
+ })),
269
+ instructions: false,
270
+ min: 1
271
+ })).agents ?? [];
272
+ if (selectedAgents.length === 0) return;
273
+ const installSpinner = spinner(`Installing ${SKILL_NAME} skill...`).start();
274
+ for (const agent of selectedAgents) installSkillForAgent(projectRoot, agent, sourceDir, SKILL_NAME);
275
+ installSpinner.succeed(`${SKILL_NAME} skill installed for ${selectedAgents.map(toDisplayName).join(", ")}.`);
276
+ };
277
+
278
+ //#endregion
17
279
  //#region src/constants.ts
18
280
  const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
19
281
  const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
@@ -140,16 +402,6 @@ const calculateScore = async (diagnostics) => {
140
402
  }
141
403
  };
142
404
 
143
- //#endregion
144
- //#region src/utils/highlighter.ts
145
- const highlighter = {
146
- error: pc.red,
147
- warn: pc.yellow,
148
- info: pc.cyan,
149
- success: pc.green,
150
- dim: pc.dim
151
- };
152
-
153
405
  //#endregion
154
406
  //#region src/utils/colorize-by-score.ts
155
407
  const colorizeByScore = (text, score) => {
@@ -414,9 +666,11 @@ const EXPO_APP_CONFIG_FILENAMES = [
414
666
  "app.config.js",
415
667
  "app.config.ts"
416
668
  ];
417
- const REACT_COMPILER_CONFIG_PATTERN = /react-compiler|reactCompiler/;
669
+ const REACT_COMPILER_PACKAGE_REFERENCE_PATTERN = /babel-plugin-react-compiler|react-compiler-runtime|eslint-plugin-react-compiler|["']react-compiler["']/;
670
+ const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*true\b/;
418
671
  const FRAMEWORK_PACKAGES = {
419
672
  next: "nextjs",
673
+ "@tanstack/react-start": "tanstack-start",
420
674
  vite: "vite",
421
675
  "react-scripts": "cra",
422
676
  "@remix-run/react": "remix",
@@ -426,6 +680,7 @@ const FRAMEWORK_PACKAGES = {
426
680
  };
427
681
  const FRAMEWORK_DISPLAY_NAMES = {
428
682
  nextjs: "Next.js",
683
+ "tanstack-start": "TanStack Start",
429
684
  vite: "Vite",
430
685
  cra: "Create React App",
431
686
  remix: "Remix",
@@ -740,12 +995,12 @@ const hasCompilerPackage = (packageJson) => {
740
995
  const allDependencies = collectAllDependencies(packageJson);
741
996
  return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
742
997
  };
743
- const fileContainsPattern = (filePath, pattern) => {
998
+ const hasCompilerInConfigFile = (filePath) => {
744
999
  if (!isFile(filePath)) return false;
745
1000
  const content = fs.readFileSync(filePath, "utf-8");
746
- return pattern.test(content);
1001
+ return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
747
1002
  };
748
- const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => fileContainsPattern(path.join(directory, filename), REACT_COMPILER_CONFIG_PATTERN));
1003
+ const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
749
1004
  const detectReactCompiler = (directory, packageJson) => {
750
1005
  if (hasCompilerPackage(packageJson)) return true;
751
1006
  if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
@@ -800,32 +1055,6 @@ const discoverProject = (directory) => {
800
1055
  };
801
1056
  };
802
1057
 
803
- //#endregion
804
- //#region src/utils/logger.ts
805
- const logger = {
806
- error(...args) {
807
- console.log(highlighter.error(args.join(" ")));
808
- },
809
- warn(...args) {
810
- console.log(highlighter.warn(args.join(" ")));
811
- },
812
- info(...args) {
813
- console.log(highlighter.info(args.join(" ")));
814
- },
815
- success(...args) {
816
- console.log(highlighter.success(args.join(" ")));
817
- },
818
- dim(...args) {
819
- console.log(highlighter.dim(args.join(" ")));
820
- },
821
- log(...args) {
822
- console.log(args.join(" "));
823
- },
824
- break() {
825
- console.log("");
826
- }
827
- };
828
-
829
1058
  //#endregion
830
1059
  //#region src/utils/framed-box.ts
831
1060
  const createFramedLine = (plainText, renderedText = plainText) => ({
@@ -906,86 +1135,6 @@ const loadConfig = (rootDirectory) => {
906
1135
  return null;
907
1136
  };
908
1137
 
909
- //#endregion
910
- //#region src/utils/should-auto-select-current-choice.ts
911
- const shouldAutoSelectCurrentChoice = (choiceStates, cursor) => {
912
- if (choiceStates.some((choiceState) => choiceState.selected)) return false;
913
- const currentChoice = choiceStates[cursor];
914
- return Boolean(currentChoice) && !currentChoice.disabled;
915
- };
916
-
917
- //#endregion
918
- //#region src/utils/should-select-all-choices.ts
919
- const shouldSelectAllChoices = (choiceStates) => {
920
- return choiceStates.filter((choiceState) => !choiceState.disabled).some((choiceState) => choiceState.selected !== true);
921
- };
922
-
923
- //#endregion
924
- //#region src/utils/prompts.ts
925
- const require = createRequire(import.meta.url);
926
- const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect";
927
- const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select";
928
- let didPatchMultiselectToggleAll = false;
929
- let didPatchMultiselectSubmit = false;
930
- let didPatchSelectBanner = false;
931
- const selectBannerMap = /* @__PURE__ */ new Map();
932
- const onCancel = () => {
933
- logger.break();
934
- logger.log("Cancelled.");
935
- logger.dim("Run `npx react-doctor@latest --fix` to fix issues.");
936
- logger.break();
937
- process.exit(0);
938
- };
939
- const patchMultiselectToggleAll = () => {
940
- if (didPatchMultiselectToggleAll) return;
941
- didPatchMultiselectToggleAll = true;
942
- const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
943
- multiselectPromptConstructor.prototype.toggleAll = function() {
944
- const isCurrentChoiceDisabled = Boolean(this.value[this.cursor]?.disabled);
945
- if (this.maxChoices !== void 0 || isCurrentChoiceDisabled) {
946
- this.bell();
947
- return;
948
- }
949
- const shouldSelectAllEnabledChoices = shouldSelectAllChoices(this.value);
950
- for (const choiceState of this.value) {
951
- if (choiceState.disabled) continue;
952
- choiceState.selected = shouldSelectAllEnabledChoices;
953
- }
954
- this.render();
955
- };
956
- };
957
- const patchMultiselectSubmit = () => {
958
- if (didPatchMultiselectSubmit) return;
959
- didPatchMultiselectSubmit = true;
960
- const multiselectPromptConstructor = require(PROMPTS_MULTISELECT_MODULE_PATH);
961
- const originalSubmit = multiselectPromptConstructor.prototype.submit;
962
- multiselectPromptConstructor.prototype.submit = function() {
963
- if (shouldAutoSelectCurrentChoice(this.value, this.cursor)) this.value[this.cursor].selected = true;
964
- originalSubmit.call(this);
965
- };
966
- };
967
- const patchSelectBanner = () => {
968
- if (didPatchSelectBanner) return;
969
- didPatchSelectBanner = true;
970
- const selectConstructor = require(PROMPTS_SELECT_MODULE_PATH);
971
- const promptsClear = require("prompts/lib/util/clear");
972
- const originalRender = selectConstructor.prototype.render;
973
- selectConstructor.prototype.render = function() {
974
- originalRender.call(this);
975
- const banner = selectBannerMap.get(this.cursor);
976
- if (!banner || this.closed || this.done) return;
977
- this.out.write(promptsClear(this.outputText, this.out.columns));
978
- this.outputText = `${banner}\n\n${this.outputText}`;
979
- this.out.write(this.outputText);
980
- };
981
- };
982
- const prompts = (questions) => {
983
- patchMultiselectToggleAll();
984
- patchMultiselectSubmit();
985
- patchSelectBanner();
986
- return basePrompts(questions, { onCancel });
987
- };
988
-
989
1138
  //#endregion
990
1139
  //#region src/utils/resolve-compatible-node.ts
991
1140
  const parseNodeVersion = (versionString) => {
@@ -1252,6 +1401,22 @@ const REACT_NATIVE_RULES = {
1252
1401
  "react-doctor/rn-prefer-reanimated": "warn",
1253
1402
  "react-doctor/rn-no-single-element-style-array": "warn"
1254
1403
  };
1404
+ const TANSTACK_START_RULES = {
1405
+ "react-doctor/tanstack-start-route-property-order": "error",
1406
+ "react-doctor/tanstack-start-no-direct-fetch-in-loader": "warn",
1407
+ "react-doctor/tanstack-start-server-fn-validate-input": "warn",
1408
+ "react-doctor/tanstack-start-no-useeffect-fetch": "warn",
1409
+ "react-doctor/tanstack-start-missing-head-content": "warn",
1410
+ "react-doctor/tanstack-start-no-anchor-element": "warn",
1411
+ "react-doctor/tanstack-start-server-fn-method-order": "error",
1412
+ "react-doctor/tanstack-start-no-navigate-in-render": "warn",
1413
+ "react-doctor/tanstack-start-no-dynamic-server-fn-import": "error",
1414
+ "react-doctor/tanstack-start-no-use-server-in-handler": "error",
1415
+ "react-doctor/tanstack-start-no-secrets-in-loader": "error",
1416
+ "react-doctor/tanstack-start-get-mutation": "warn",
1417
+ "react-doctor/tanstack-start-redirect-in-try-catch": "warn",
1418
+ "react-doctor/tanstack-start-loader-parallel-fetch": "warn"
1419
+ };
1255
1420
  const REACT_COMPILER_RULES = {
1256
1421
  "react-hooks-js/set-state-in-render": "error",
1257
1422
  "react-hooks-js/immutability": "error",
@@ -1341,12 +1506,14 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRul
1341
1506
  "react-doctor/rendering-animate-svg-wrapper": "warn",
1342
1507
  "react-doctor/no-inline-prop-on-memo-component": "warn",
1343
1508
  "react-doctor/rendering-hydration-no-flicker": "warn",
1509
+ "react-doctor/rendering-script-defer-async": "warn",
1344
1510
  "react-doctor/no-transition-all": "warn",
1345
1511
  "react-doctor/no-global-css-variable-animation": "error",
1346
1512
  "react-doctor/no-large-animated-blur": "warn",
1347
1513
  "react-doctor/no-scale-from-zero": "warn",
1348
1514
  "react-doctor/no-permanent-will-change": "warn",
1349
1515
  "react-doctor/no-secrets-in-client-code": "error",
1516
+ "react-doctor/js-flatmap-filter": "warn",
1350
1517
  "react-doctor/no-barrel-import": "warn",
1351
1518
  "react-doctor/no-full-lodash-import": "warn",
1352
1519
  "react-doctor/no-moment": "warn",
@@ -1359,9 +1526,31 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, customRul
1359
1526
  "react-doctor/server-auth-actions": "error",
1360
1527
  "react-doctor/server-after-nonblocking": "warn",
1361
1528
  "react-doctor/client-passive-event-listeners": "warn",
1529
+ "react-doctor/query-stable-query-client": "error",
1530
+ "react-doctor/query-no-rest-destructuring": "warn",
1531
+ "react-doctor/query-no-void-query-fn": "warn",
1532
+ "react-doctor/query-no-query-in-effect": "warn",
1533
+ "react-doctor/query-mutation-missing-invalidation": "warn",
1534
+ "react-doctor/query-no-usequery-for-mutation": "warn",
1535
+ "react-doctor/no-inline-bounce-easing": "warn",
1536
+ "react-doctor/no-z-index-9999": "warn",
1537
+ "react-doctor/no-inline-exhaustive-style": "warn",
1538
+ "react-doctor/no-side-tab-border": "warn",
1539
+ "react-doctor/no-pure-black-background": "warn",
1540
+ "react-doctor/no-gradient-text": "warn",
1541
+ "react-doctor/no-dark-mode-glow": "warn",
1542
+ "react-doctor/no-justified-text": "warn",
1543
+ "react-doctor/no-tiny-text": "warn",
1544
+ "react-doctor/no-wide-letter-spacing": "warn",
1545
+ "react-doctor/no-gray-on-colored-background": "warn",
1546
+ "react-doctor/no-layout-transition-inline": "warn",
1547
+ "react-doctor/no-disabled-zoom": "error",
1548
+ "react-doctor/no-outline-none": "warn",
1549
+ "react-doctor/no-long-transition-duration": "warn",
1362
1550
  "react-doctor/async-parallel": "warn",
1363
1551
  ...framework === "nextjs" ? NEXTJS_RULES : {},
1364
- ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {}
1552
+ ...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
1553
+ ...framework === "tanstack-start" ? TANSTACK_START_RULES : {}
1365
1554
  }
1366
1555
  });
1367
1556
 
@@ -1438,6 +1627,7 @@ const RULE_CATEGORY_MAP = {
1438
1627
  "react-doctor/rendering-animate-svg-wrapper": "Performance",
1439
1628
  "react-doctor/rendering-usetransition-loading": "Performance",
1440
1629
  "react-doctor/rendering-hydration-no-flicker": "Performance",
1630
+ "react-doctor/rendering-script-defer-async": "Performance",
1441
1631
  "react-doctor/no-transition-all": "Performance",
1442
1632
  "react-doctor/no-global-css-variable-animation": "Performance",
1443
1633
  "react-doctor/no-large-animated-blur": "Performance",
@@ -1472,6 +1662,28 @@ const RULE_CATEGORY_MAP = {
1472
1662
  "react-doctor/server-auth-actions": "Server",
1473
1663
  "react-doctor/server-after-nonblocking": "Server",
1474
1664
  "react-doctor/client-passive-event-listeners": "Performance",
1665
+ "react-doctor/query-stable-query-client": "TanStack Query",
1666
+ "react-doctor/query-no-rest-destructuring": "TanStack Query",
1667
+ "react-doctor/query-no-void-query-fn": "TanStack Query",
1668
+ "react-doctor/query-no-query-in-effect": "TanStack Query",
1669
+ "react-doctor/query-mutation-missing-invalidation": "TanStack Query",
1670
+ "react-doctor/query-no-usequery-for-mutation": "TanStack Query",
1671
+ "react-doctor/no-inline-bounce-easing": "Performance",
1672
+ "react-doctor/no-z-index-9999": "Architecture",
1673
+ "react-doctor/no-inline-exhaustive-style": "Architecture",
1674
+ "react-doctor/no-side-tab-border": "Architecture",
1675
+ "react-doctor/no-pure-black-background": "Architecture",
1676
+ "react-doctor/no-gradient-text": "Architecture",
1677
+ "react-doctor/no-dark-mode-glow": "Architecture",
1678
+ "react-doctor/no-justified-text": "Accessibility",
1679
+ "react-doctor/no-tiny-text": "Accessibility",
1680
+ "react-doctor/no-wide-letter-spacing": "Architecture",
1681
+ "react-doctor/no-gray-on-colored-background": "Accessibility",
1682
+ "react-doctor/no-layout-transition-inline": "Performance",
1683
+ "react-doctor/no-disabled-zoom": "Accessibility",
1684
+ "react-doctor/no-outline-none": "Accessibility",
1685
+ "react-doctor/no-long-transition-duration": "Performance",
1686
+ "react-doctor/js-flatmap-filter": "Performance",
1475
1687
  "react-doctor/async-parallel": "Performance",
1476
1688
  "react-doctor/rn-no-raw-text": "React Native",
1477
1689
  "react-doctor/rn-no-deprecated-modules": "React Native",
@@ -1480,7 +1692,21 @@ const RULE_CATEGORY_MAP = {
1480
1692
  "react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
1481
1693
  "react-doctor/rn-no-legacy-shadow-styles": "React Native",
1482
1694
  "react-doctor/rn-prefer-reanimated": "React Native",
1483
- "react-doctor/rn-no-single-element-style-array": "React Native"
1695
+ "react-doctor/rn-no-single-element-style-array": "React Native",
1696
+ "react-doctor/tanstack-start-route-property-order": "TanStack Start",
1697
+ "react-doctor/tanstack-start-no-direct-fetch-in-loader": "TanStack Start",
1698
+ "react-doctor/tanstack-start-server-fn-validate-input": "TanStack Start",
1699
+ "react-doctor/tanstack-start-no-useeffect-fetch": "TanStack Start",
1700
+ "react-doctor/tanstack-start-missing-head-content": "TanStack Start",
1701
+ "react-doctor/tanstack-start-no-anchor-element": "TanStack Start",
1702
+ "react-doctor/tanstack-start-server-fn-method-order": "TanStack Start",
1703
+ "react-doctor/tanstack-start-no-navigate-in-render": "TanStack Start",
1704
+ "react-doctor/tanstack-start-no-dynamic-server-fn-import": "TanStack Start",
1705
+ "react-doctor/tanstack-start-no-use-server-in-handler": "TanStack Start",
1706
+ "react-doctor/tanstack-start-no-secrets-in-loader": "Security",
1707
+ "react-doctor/tanstack-start-get-mutation": "Security",
1708
+ "react-doctor/tanstack-start-redirect-in-try-catch": "TanStack Start",
1709
+ "react-doctor/tanstack-start-loader-parallel-fetch": "Performance"
1484
1710
  };
1485
1711
  const RULE_HELP_MAP = {
1486
1712
  "no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`. See https://react.dev/learn/you-might-not-need-an-effect",
@@ -1502,6 +1728,7 @@ const RULE_HELP_MAP = {
1502
1728
  "rendering-animate-svg-wrapper": "Wrap the SVG: `<motion.div animate={...}><svg>...</svg></motion.div>`",
1503
1729
  "rendering-usetransition-loading": "Replace with `const [isPending, startTransition] = useTransition()` — avoids a re-render for the loading state",
1504
1730
  "rendering-hydration-no-flicker": "Use `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)` or add `suppressHydrationWarning` to the element",
1731
+ "rendering-script-defer-async": "Add `defer` for DOM-dependent scripts or `async` for independent ones (analytics). In Next.js, use `<Script strategy=\"afterInteractive\" />` instead",
1505
1732
  "no-transition-all": "List specific properties: `transition: \"opacity 200ms, transform 200ms\"` — or in Tailwind use `transition-colors`, `transition-opacity`, or `transition-transform`",
1506
1733
  "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",
1507
1734
  "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",
@@ -1514,6 +1741,21 @@ const RULE_HELP_MAP = {
1514
1741
  "prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
1515
1742
  "use-lazy-motion": "Use `import { LazyMotion, m } from \"framer-motion\"` with `domAnimation` features — saves ~30kb",
1516
1743
  "no-undeferred-third-party": "Use `next/script` with `strategy=\"lazyOnload\"` or add the `defer` attribute",
1744
+ "no-inline-bounce-easing": "Use `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-expo) for natural deceleration — objects in the real world don't bounce",
1745
+ "no-z-index-9999": "Define a z-index scale in your design tokens (e.g. dropdown: 10, modal: 20, toast: 30). Create a new stacking context with `isolation: isolate` instead of escalating values",
1746
+ "no-inline-exhaustive-style": "Move styles to a CSS class, CSS module, Tailwind utilities, or a styled component — inline objects with many properties hurt readability and create new references every render",
1747
+ "no-side-tab-border": "Use a subtler accent (box-shadow inset, background gradient, or border-bottom) instead of a thick one-sided border",
1748
+ "no-pure-black-background": "Tint the background slightly toward your brand hue — e.g. `#0a0a0f` or Tailwind's `bg-gray-950`. Pure black looks harsh on modern displays",
1749
+ "no-gradient-text": "Use solid text colors for readability. If you need emphasis, use font weight, size, or a distinct color instead of gradients",
1750
+ "no-dark-mode-glow": "Use a subtle `box-shadow` with neutral colors for depth, or `border` with low opacity. Colored glows on dark backgrounds are the default AI-generated aesthetic",
1751
+ "no-justified-text": "Use `text-align: left` for body text, or add `hyphens: auto` and `overflow-wrap: break-word` if you must justify",
1752
+ "no-tiny-text": "Use at least 12px for body content, 16px is ideal. Small text is hard to read, especially on high-DPI mobile screens",
1753
+ "no-wide-letter-spacing": "Reserve wide tracking (letter-spacing > 0.05em) for short uppercase labels, navigation items, and buttons — not body text",
1754
+ "no-gray-on-colored-background": "Use a darker shade of the background color for text, or white/near-white for contrast. Gray text on colored backgrounds looks washed out",
1755
+ "no-layout-transition-inline": "Use `transform` and `opacity` for transitions — they run on the compositor thread. For height animations, use `grid-template-rows: 0fr → 1fr`",
1756
+ "no-disabled-zoom": "Remove `user-scalable=no` and `maximum-scale` from the viewport meta tag. If your layout breaks at 200% zoom, fix the layout — don't punish users with disabilities",
1757
+ "no-outline-none": "Use `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px }` to show focus only for keyboard users while hiding it for mouse clicks",
1758
+ "no-long-transition-duration": "Keep UI transitions under 1s — 100-150ms for instant feedback, 200-300ms for state changes, 300-500ms for layout changes. Use longer durations only for page-load hero animations",
1517
1759
  "no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
1518
1760
  "rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
1519
1761
  "no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
@@ -1523,7 +1765,7 @@ const RULE_HELP_MAP = {
1523
1765
  "nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
1524
1766
  "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",
1525
1767
  "nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
1526
- "nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' directly (works in both server and client components), or handle in middleware",
1768
+ "nextjs-no-client-side-redirect": "Avoid redirects inside useEffect. Use an event handler, middleware, or server-side redirect (App Router: redirect() from next/navigation; Pages Router: getServerSideProps redirect)",
1527
1769
  "nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
1528
1770
  "nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
1529
1771
  "nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
@@ -1536,6 +1778,13 @@ const RULE_HELP_MAP = {
1536
1778
  "server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
1537
1779
  "server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
1538
1780
  "client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
1781
+ "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",
1782
+ "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",
1783
+ "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",
1784
+ "query-no-query-in-effect": "React Query manages refetching automatically via queryKey dependencies and the `enabled` option — manual refetch() in useEffect is usually unnecessary",
1785
+ "query-mutation-missing-invalidation": "Add `onSuccess: () => queryClient.invalidateQueries({ queryKey: ['...'] })` so cached data stays in sync after the mutation",
1786
+ "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",
1787
+ "js-flatmap-filter": "Use `.flatMap(item => condition ? [value] : [])` — transforms and filters in a single pass instead of creating an intermediate array",
1539
1788
  "async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
1540
1789
  "rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
1541
1790
  "rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
@@ -1544,7 +1793,21 @@ const RULE_HELP_MAP = {
1544
1793
  "rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
1545
1794
  "rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
1546
1795
  "rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
1547
- "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation"
1796
+ "rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation",
1797
+ "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",
1798
+ "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",
1799
+ "tanstack-start-server-fn-validate-input": "Add `.inputValidator(schema)` before `.handler()` — data crosses a network boundary and must be validated at runtime",
1800
+ "tanstack-start-no-useeffect-fetch": "Fetch data in the route `loader` instead — the router coordinates loading before rendering to avoid waterfalls",
1801
+ "tanstack-start-missing-head-content": "Add `<HeadContent />` inside `<head>` in your __root route — without it, route `head()` meta tags are silently dropped",
1802
+ "tanstack-start-no-anchor-element": "`import { Link } from '@tanstack/react-router'` — enables type-safe routes, preloading via `preload=\"intent\"`, and client-side navigation",
1803
+ "tanstack-start-server-fn-method-order": "Chain methods in order: .middleware() → .inputValidator() → .client() → .server() → .handler() — types depend on this sequence",
1804
+ "tanstack-start-no-navigate-in-render": "Use `throw redirect({ to: '/path' })` in `beforeLoad` or `loader` instead — navigate() during render causes hydration issues",
1805
+ "tanstack-start-no-dynamic-server-fn-import": "Use `import { myFn } from '~/utils/my.functions'` — the bundler replaces server code with RPC stubs only for static imports",
1806
+ "tanstack-start-no-use-server-in-handler": "TanStack Start handles server boundaries automatically via the Vite plugin — \"use server\" inside createServerFn causes compilation errors",
1807
+ "tanstack-start-no-secrets-in-loader": "Loaders are isomorphic (run on both server and client). Wrap secret access in `createServerFn()` so it stays server-only",
1808
+ "tanstack-start-get-mutation": "Use `createServerFn({ method: 'POST' })` for data modifications — GET requests can be triggered by prefetching and are vulnerable to CSRF",
1809
+ "tanstack-start-redirect-in-try-catch": "TanStack Router's `redirect()` and `notFound()` throw special errors caught by the router. Move them outside the try block or re-throw in the catch",
1810
+ "tanstack-start-loader-parallel-fetch": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to avoid request waterfalls in route loaders"
1548
1811
  };
1549
1812
  const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
1550
1813
  const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
@@ -1690,37 +1953,6 @@ const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompil
1690
1953
  }
1691
1954
  };
1692
1955
 
1693
- //#endregion
1694
- //#region src/utils/spinner.ts
1695
- let sharedInstance = null;
1696
- let activeCount = 0;
1697
- const pendingTexts = /* @__PURE__ */ new Set();
1698
- const finalize = (method, originalText, displayText) => {
1699
- pendingTexts.delete(originalText);
1700
- activeCount--;
1701
- if (activeCount <= 0 || !sharedInstance) {
1702
- sharedInstance?.[method](displayText);
1703
- sharedInstance = null;
1704
- activeCount = 0;
1705
- return;
1706
- }
1707
- sharedInstance.stop();
1708
- ora(displayText).start()[method](displayText);
1709
- const [remainingText] = pendingTexts;
1710
- if (remainingText) sharedInstance.text = remainingText;
1711
- sharedInstance.start();
1712
- };
1713
- const spinner = (text) => ({ start() {
1714
- activeCount++;
1715
- pendingTexts.add(text);
1716
- if (!sharedInstance) sharedInstance = ora({ text }).start();
1717
- else sharedInstance.text = text;
1718
- return {
1719
- succeed: (displayText) => finalize("succeed", text, displayText),
1720
- fail: (displayText) => finalize("fail", text, displayText)
1721
- };
1722
- } });
1723
-
1724
1956
  //#endregion
1725
1957
  //#region src/scan.ts
1726
1958
  const SEVERITY_ORDER = {
@@ -2280,7 +2512,7 @@ const promptProjectSelection = async (workspacePackages, rootDirectory) => {
2280
2512
 
2281
2513
  //#endregion
2282
2514
  //#region src/cli.ts
2283
- const VERSION = "0.0.34";
2515
+ const VERSION = "0.0.36";
2284
2516
  const VALID_FAIL_ON_LEVELS = new Set([
2285
2517
  "error",
2286
2518
  "warning",
@@ -2438,6 +2670,13 @@ const program = new Command().name("react-doctor").description("Diagnose React c
2438
2670
  ${highlighter.dim("Learn more:")}
2439
2671
  ${highlighter.info("https://github.com/millionco/react-doctor")}
2440
2672
  `);
2673
+ 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) => {
2674
+ try {
2675
+ await runInstallSkill({ yes: options.yes });
2676
+ } catch (error) {
2677
+ handleError(error);
2678
+ }
2679
+ });
2441
2680
  const main$1 = async () => {
2442
2681
  await program.parseAsync();
2443
2682
  };