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