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