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