react-doctor 0.1.6 → 0.2.0-beta.0
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 +167 -130
- package/dist/cli.js +19865 -4136
- package/dist/compat-CM6aj69a.js +1851 -0
- package/dist/compat.d.ts +53 -0
- package/dist/compat.js +3 -0
- package/dist/errors-ZdckckLr.d.ts +87 -0
- package/dist/eslint-plugin-BIjw2MeW.d.ts +105 -0
- package/dist/eslint-plugin.d.ts +2 -57
- package/dist/eslint-plugin.js +32 -6946
- package/dist/index-CFzh1cBi.d.ts +1798 -0
- package/dist/metadata-se470mRG.js +604 -0
- package/dist/oxlint-plugin.d.ts +2 -0
- package/dist/oxlint-plugin.js +7 -0
- package/dist/rules-BfZ4Ujfv.js +16701 -0
- package/dist/rules-ebKa330H.d.ts +28 -0
- package/dist/score-CzbtoFAu.js +69 -0
- package/dist/score.d.ts +35 -0
- package/dist/score.js +2 -0
- package/dist/sdk.d.ts +90 -0
- package/dist/sdk.js +17 -0
- package/package.json +22 -27
- package/dist/index.d.ts +0 -272
- package/dist/index.js +0 -3376
- package/dist/react-doctor-plugin.d.ts +0 -31
- package/dist/react-doctor-plugin.js +0 -6720
package/dist/index.js
DELETED
|
@@ -1,3376 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import pc from "picocolors";
|
|
6
|
-
import { main } from "knip";
|
|
7
|
-
import { createOptions } from "knip/session";
|
|
8
|
-
import os from "node:os";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
|
-
//#region src/constants.ts
|
|
11
|
-
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
12
|
-
const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
|
|
13
|
-
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
14
|
-
const FETCH_TIMEOUT_MS = 1e4;
|
|
15
|
-
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
16
|
-
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
17
|
-
const ERROR_RULE_PENALTY = 1.5;
|
|
18
|
-
const WARNING_RULE_PENALTY = .75;
|
|
19
|
-
const KNIP_CONFIG_LOCATIONS = [
|
|
20
|
-
"knip.json",
|
|
21
|
-
"knip.jsonc",
|
|
22
|
-
".knip.json",
|
|
23
|
-
".knip.jsonc",
|
|
24
|
-
"knip.ts",
|
|
25
|
-
"knip.js",
|
|
26
|
-
"knip.config.ts",
|
|
27
|
-
"knip.config.js"
|
|
28
|
-
];
|
|
29
|
-
const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
|
|
30
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
31
|
-
".git",
|
|
32
|
-
".next",
|
|
33
|
-
".nuxt",
|
|
34
|
-
".output",
|
|
35
|
-
".svelte-kit",
|
|
36
|
-
".turbo",
|
|
37
|
-
"build",
|
|
38
|
-
"coverage",
|
|
39
|
-
"dist",
|
|
40
|
-
"node_modules",
|
|
41
|
-
"out",
|
|
42
|
-
"storybook-static"
|
|
43
|
-
]);
|
|
44
|
-
const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
45
|
-
const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
|
|
46
|
-
//#endregion
|
|
47
|
-
//#region src/errors.ts
|
|
48
|
-
var ReactDoctorError = class extends Error {
|
|
49
|
-
name = "ReactDoctorError";
|
|
50
|
-
constructor(message, options) {
|
|
51
|
-
super(message, options);
|
|
52
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
var ProjectNotFoundError = class extends ReactDoctorError {
|
|
56
|
-
name = "ProjectNotFoundError";
|
|
57
|
-
directory;
|
|
58
|
-
constructor(directory, options) {
|
|
59
|
-
super(`No React project found in ${directory}. Expected a package.json at the directory root or a nested package.json with a React dependency.`, options);
|
|
60
|
-
this.directory = directory;
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
var NoReactDependencyError = class extends ReactDoctorError {
|
|
64
|
-
name = "NoReactDependencyError";
|
|
65
|
-
directory;
|
|
66
|
-
constructor(directory, options) {
|
|
67
|
-
super(buildNoReactDependencyError(directory), options);
|
|
68
|
-
this.directory = directory;
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
var PackageJsonNotFoundError = class extends ReactDoctorError {
|
|
72
|
-
name = "PackageJsonNotFoundError";
|
|
73
|
-
directory;
|
|
74
|
-
constructor(directory, options) {
|
|
75
|
-
super(`No package.json found in ${directory}`, options);
|
|
76
|
-
this.directory = directory;
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
var AmbiguousProjectError = class extends ReactDoctorError {
|
|
80
|
-
name = "AmbiguousProjectError";
|
|
81
|
-
directory;
|
|
82
|
-
candidates;
|
|
83
|
-
constructor(directory, candidates, options) {
|
|
84
|
-
super(`Multiple React projects found under ${directory} (${candidates.length} candidates): ${candidates.join(", ")}. Re-run diagnose() with one of those subdirectories, or iterate them yourself.`, options);
|
|
85
|
-
this.directory = directory;
|
|
86
|
-
this.candidates = candidates;
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
const isReactDoctorError = (value) => value instanceof ReactDoctorError;
|
|
90
|
-
//#endregion
|
|
91
|
-
//#region src/utils/summarize-diagnostics.ts
|
|
92
|
-
const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
|
|
93
|
-
let errorCount = 0;
|
|
94
|
-
let warningCount = 0;
|
|
95
|
-
const affectedFiles = /* @__PURE__ */ new Set();
|
|
96
|
-
for (const diagnostic of diagnostics) {
|
|
97
|
-
if (diagnostic.severity === "error") errorCount++;
|
|
98
|
-
else warningCount++;
|
|
99
|
-
affectedFiles.add(diagnostic.filePath);
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
errorCount,
|
|
103
|
-
warningCount,
|
|
104
|
-
affectedFileCount: affectedFiles.size,
|
|
105
|
-
totalDiagnosticCount: diagnostics.length,
|
|
106
|
-
score: worstScore,
|
|
107
|
-
scoreLabel: worstScoreLabel
|
|
108
|
-
};
|
|
109
|
-
};
|
|
110
|
-
//#endregion
|
|
111
|
-
//#region src/utils/build-json-report.ts
|
|
112
|
-
const toJsonDiff = (diff) => {
|
|
113
|
-
if (!diff) return null;
|
|
114
|
-
return {
|
|
115
|
-
baseBranch: diff.baseBranch,
|
|
116
|
-
currentBranch: diff.currentBranch,
|
|
117
|
-
changedFileCount: diff.changedFiles.length,
|
|
118
|
-
isCurrentChanges: Boolean(diff.isCurrentChanges)
|
|
119
|
-
};
|
|
120
|
-
};
|
|
121
|
-
const findWorstScoredProject = (projects) => {
|
|
122
|
-
let worst = null;
|
|
123
|
-
let worstScore = Number.POSITIVE_INFINITY;
|
|
124
|
-
for (const project of projects) {
|
|
125
|
-
const score = project.score?.score;
|
|
126
|
-
if (typeof score !== "number") continue;
|
|
127
|
-
if (score < worstScore) {
|
|
128
|
-
worstScore = score;
|
|
129
|
-
worst = project;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return worst;
|
|
133
|
-
};
|
|
134
|
-
const buildJsonReport = (input) => {
|
|
135
|
-
const projects = input.scans.map(({ directory, result }) => ({
|
|
136
|
-
directory,
|
|
137
|
-
project: result.project,
|
|
138
|
-
diagnostics: result.diagnostics,
|
|
139
|
-
score: result.score,
|
|
140
|
-
skippedChecks: result.skippedChecks,
|
|
141
|
-
elapsedMilliseconds: result.elapsedMilliseconds
|
|
142
|
-
}));
|
|
143
|
-
const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
|
|
144
|
-
const worstScoredProject = findWorstScoredProject(projects);
|
|
145
|
-
const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
|
|
146
|
-
return {
|
|
147
|
-
schemaVersion: 1,
|
|
148
|
-
version: input.version,
|
|
149
|
-
ok: true,
|
|
150
|
-
directory: input.directory,
|
|
151
|
-
mode: input.mode,
|
|
152
|
-
diff: toJsonDiff(input.diff),
|
|
153
|
-
projects,
|
|
154
|
-
diagnostics: flattenedDiagnostics,
|
|
155
|
-
summary,
|
|
156
|
-
elapsedMilliseconds: input.totalElapsedMilliseconds,
|
|
157
|
-
error: null
|
|
158
|
-
};
|
|
159
|
-
};
|
|
160
|
-
//#endregion
|
|
161
|
-
//#region src/utils/format-error-chain.ts
|
|
162
|
-
const collectErrorChain = (rootError) => {
|
|
163
|
-
const errorChain = [];
|
|
164
|
-
const visitedErrors = /* @__PURE__ */ new Set();
|
|
165
|
-
let currentError = rootError;
|
|
166
|
-
while (currentError !== void 0 && !visitedErrors.has(currentError)) {
|
|
167
|
-
visitedErrors.add(currentError);
|
|
168
|
-
errorChain.push(currentError);
|
|
169
|
-
currentError = currentError instanceof Error ? currentError.cause : void 0;
|
|
170
|
-
}
|
|
171
|
-
return errorChain;
|
|
172
|
-
};
|
|
173
|
-
const formatErrorMessage = (error) => error instanceof Error ? error.message || error.name : String(error);
|
|
174
|
-
const getErrorChainMessages = (rootError) => collectErrorChain(rootError).map(formatErrorMessage);
|
|
175
|
-
//#endregion
|
|
176
|
-
//#region src/utils/build-json-report-error.ts
|
|
177
|
-
const safeStringify = (value) => {
|
|
178
|
-
try {
|
|
179
|
-
return String(value);
|
|
180
|
-
} catch {
|
|
181
|
-
return "Unrepresentable error";
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
const safeGetErrorChain = (error) => {
|
|
185
|
-
try {
|
|
186
|
-
return getErrorChainMessages(error);
|
|
187
|
-
} catch {
|
|
188
|
-
return [safeStringify(error)];
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
const buildJsonReportError = (input) => {
|
|
192
|
-
const chain = safeGetErrorChain(input.error);
|
|
193
|
-
const errorPayload = input.error instanceof Error ? {
|
|
194
|
-
message: input.error.message || input.error.name || "Error",
|
|
195
|
-
name: input.error.name || "Error",
|
|
196
|
-
chain
|
|
197
|
-
} : {
|
|
198
|
-
message: safeStringify(input.error),
|
|
199
|
-
name: "Error",
|
|
200
|
-
chain
|
|
201
|
-
};
|
|
202
|
-
return {
|
|
203
|
-
schemaVersion: 1,
|
|
204
|
-
version: input.version,
|
|
205
|
-
ok: false,
|
|
206
|
-
directory: input.directory,
|
|
207
|
-
mode: input.mode ?? "full",
|
|
208
|
-
diff: null,
|
|
209
|
-
projects: [],
|
|
210
|
-
diagnostics: [],
|
|
211
|
-
summary: {
|
|
212
|
-
errorCount: 0,
|
|
213
|
-
warningCount: 0,
|
|
214
|
-
affectedFileCount: 0,
|
|
215
|
-
totalDiagnosticCount: 0,
|
|
216
|
-
score: null,
|
|
217
|
-
scoreLabel: null
|
|
218
|
-
},
|
|
219
|
-
elapsedMilliseconds: input.elapsedMilliseconds,
|
|
220
|
-
error: errorPayload
|
|
221
|
-
};
|
|
222
|
-
};
|
|
223
|
-
//#endregion
|
|
224
|
-
//#region src/utils/calculate-score-locally.ts
|
|
225
|
-
const getScoreLabel = (score) => {
|
|
226
|
-
if (score >= 75) return "Great";
|
|
227
|
-
if (score >= 50) return "Needs work";
|
|
228
|
-
return "Critical";
|
|
229
|
-
};
|
|
230
|
-
const countUniqueRules = (diagnostics) => {
|
|
231
|
-
const errorRules = /* @__PURE__ */ new Set();
|
|
232
|
-
const warningRules = /* @__PURE__ */ new Set();
|
|
233
|
-
for (const diagnostic of diagnostics) {
|
|
234
|
-
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
235
|
-
if (diagnostic.severity === "error") errorRules.add(ruleKey);
|
|
236
|
-
else warningRules.add(ruleKey);
|
|
237
|
-
}
|
|
238
|
-
return {
|
|
239
|
-
errorRuleCount: errorRules.size,
|
|
240
|
-
warningRuleCount: warningRules.size
|
|
241
|
-
};
|
|
242
|
-
};
|
|
243
|
-
const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
|
|
244
|
-
const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
|
|
245
|
-
return Math.max(0, Math.round(100 - penalty));
|
|
246
|
-
};
|
|
247
|
-
const calculateScoreLocally = (diagnostics) => {
|
|
248
|
-
const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
|
|
249
|
-
const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
|
|
250
|
-
return {
|
|
251
|
-
score,
|
|
252
|
-
label: getScoreLabel(score)
|
|
253
|
-
};
|
|
254
|
-
};
|
|
255
|
-
//#endregion
|
|
256
|
-
//#region src/utils/try-score-from-api.ts
|
|
257
|
-
const parseScoreResult = (value) => {
|
|
258
|
-
if (typeof value !== "object" || value === null) return null;
|
|
259
|
-
if (!("score" in value) || !("label" in value)) return null;
|
|
260
|
-
const scoreValue = Reflect.get(value, "score");
|
|
261
|
-
const labelValue = Reflect.get(value, "label");
|
|
262
|
-
if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
|
|
263
|
-
return {
|
|
264
|
-
score: scoreValue,
|
|
265
|
-
label: labelValue
|
|
266
|
-
};
|
|
267
|
-
};
|
|
268
|
-
const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
|
|
269
|
-
const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
270
|
-
const describeFailure = (error) => {
|
|
271
|
-
if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
|
|
272
|
-
if (error instanceof Error && error.message) return error.message;
|
|
273
|
-
return String(error);
|
|
274
|
-
};
|
|
275
|
-
const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
|
|
276
|
-
if (typeof fetchImplementation !== "function") return null;
|
|
277
|
-
const controller = new AbortController();
|
|
278
|
-
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
279
|
-
try {
|
|
280
|
-
const response = await fetchImplementation(SCORE_API_URL, {
|
|
281
|
-
method: "POST",
|
|
282
|
-
headers: { "Content-Type": "application/json" },
|
|
283
|
-
body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
|
|
284
|
-
signal: controller.signal
|
|
285
|
-
});
|
|
286
|
-
if (!response.ok) {
|
|
287
|
-
console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
|
|
288
|
-
return null;
|
|
289
|
-
}
|
|
290
|
-
return parseScoreResult(await response.json());
|
|
291
|
-
} catch (error) {
|
|
292
|
-
console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
|
|
293
|
-
return null;
|
|
294
|
-
} finally {
|
|
295
|
-
clearTimeout(timeoutId);
|
|
296
|
-
}
|
|
297
|
-
};
|
|
298
|
-
//#endregion
|
|
299
|
-
//#region src/utils/proxy-fetch.ts
|
|
300
|
-
const getGlobalProcess = () => {
|
|
301
|
-
const candidate = globalThis.process;
|
|
302
|
-
return candidate?.versions?.node ? candidate : void 0;
|
|
303
|
-
};
|
|
304
|
-
const getProxyUrl = () => {
|
|
305
|
-
const proc = getGlobalProcess();
|
|
306
|
-
if (!proc?.env) return void 0;
|
|
307
|
-
return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
|
|
308
|
-
};
|
|
309
|
-
const createProxyDispatcher = async (proxyUrl) => {
|
|
310
|
-
try {
|
|
311
|
-
const { ProxyAgent } = await import("undici");
|
|
312
|
-
return new ProxyAgent(proxyUrl);
|
|
313
|
-
} catch {
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
};
|
|
317
|
-
const proxyFetch = async (url, init) => {
|
|
318
|
-
const proxyUrl = getProxyUrl();
|
|
319
|
-
const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
|
|
320
|
-
const fetchInit = {
|
|
321
|
-
...init,
|
|
322
|
-
...dispatcher ? { dispatcher } : {}
|
|
323
|
-
};
|
|
324
|
-
return fetch(url, fetchInit);
|
|
325
|
-
};
|
|
326
|
-
//#endregion
|
|
327
|
-
//#region src/utils/calculate-score.ts
|
|
328
|
-
const calculateScore = async (diagnostics) => await tryScoreFromApi(diagnostics, proxyFetch) ?? calculateScoreLocally(diagnostics);
|
|
329
|
-
//#endregion
|
|
330
|
-
//#region src/plugin/constants.ts
|
|
331
|
-
const FETCH_CALLEE_NAMES = new Set([
|
|
332
|
-
"fetch",
|
|
333
|
-
"ky",
|
|
334
|
-
"got",
|
|
335
|
-
"wretch",
|
|
336
|
-
"ofetch"
|
|
337
|
-
]);
|
|
338
|
-
const FETCH_MEMBER_OBJECTS = new Set([
|
|
339
|
-
"axios",
|
|
340
|
-
"ky",
|
|
341
|
-
"got",
|
|
342
|
-
"ofetch",
|
|
343
|
-
"wretch",
|
|
344
|
-
"request"
|
|
345
|
-
]);
|
|
346
|
-
const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([
|
|
347
|
-
"setTimeout",
|
|
348
|
-
"setInterval",
|
|
349
|
-
"requestAnimationFrame",
|
|
350
|
-
"requestIdleCallback",
|
|
351
|
-
"queueMicrotask"
|
|
352
|
-
]);
|
|
353
|
-
const SUBSCRIPTION_METHOD_NAMES = new Set([
|
|
354
|
-
"subscribe",
|
|
355
|
-
"addEventListener",
|
|
356
|
-
"addListener",
|
|
357
|
-
"on",
|
|
358
|
-
"watch",
|
|
359
|
-
"listen",
|
|
360
|
-
"sub"
|
|
361
|
-
]);
|
|
362
|
-
new Set([
|
|
363
|
-
...new Set([
|
|
364
|
-
"unsubscribe",
|
|
365
|
-
"removeEventListener",
|
|
366
|
-
"removeListener",
|
|
367
|
-
"off",
|
|
368
|
-
"unwatch",
|
|
369
|
-
"unlisten",
|
|
370
|
-
"unsub"
|
|
371
|
-
]),
|
|
372
|
-
"cleanup",
|
|
373
|
-
"dispose",
|
|
374
|
-
"destroy",
|
|
375
|
-
"teardown"
|
|
376
|
-
]);
|
|
377
|
-
new Set([
|
|
378
|
-
...SUBSCRIPTION_METHOD_NAMES,
|
|
379
|
-
"connect",
|
|
380
|
-
"disconnect",
|
|
381
|
-
"open",
|
|
382
|
-
"close",
|
|
383
|
-
"fetch",
|
|
384
|
-
"post",
|
|
385
|
-
"put",
|
|
386
|
-
"patch"
|
|
387
|
-
]);
|
|
388
|
-
new Set([
|
|
389
|
-
...FETCH_MEMBER_OBJECTS,
|
|
390
|
-
"api",
|
|
391
|
-
"client",
|
|
392
|
-
"http",
|
|
393
|
-
"fetcher"
|
|
394
|
-
]);
|
|
395
|
-
new Set([...FETCH_CALLEE_NAMES, ...TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES]);
|
|
396
|
-
new Set([
|
|
397
|
-
...FETCH_CALLEE_NAMES,
|
|
398
|
-
"post",
|
|
399
|
-
"put",
|
|
400
|
-
"patch",
|
|
401
|
-
"navigate",
|
|
402
|
-
"navigateTo",
|
|
403
|
-
"showNotification",
|
|
404
|
-
"toast",
|
|
405
|
-
"alert",
|
|
406
|
-
"confirm",
|
|
407
|
-
"logVisit",
|
|
408
|
-
"captureEvent"
|
|
409
|
-
]);
|
|
410
|
-
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
411
|
-
//#endregion
|
|
412
|
-
//#region src/utils/is-file.ts
|
|
413
|
-
const isFile = (filePath) => {
|
|
414
|
-
try {
|
|
415
|
-
return fs.statSync(filePath).isFile();
|
|
416
|
-
} catch {
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
419
|
-
};
|
|
420
|
-
//#endregion
|
|
421
|
-
//#region src/utils/read-package-json.ts
|
|
422
|
-
const cachedPackageJsons = /* @__PURE__ */ new Map();
|
|
423
|
-
const clearPackageJsonCache = () => {
|
|
424
|
-
cachedPackageJsons.clear();
|
|
425
|
-
};
|
|
426
|
-
const readPackageJsonUncached = (packageJsonPath) => {
|
|
427
|
-
try {
|
|
428
|
-
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
429
|
-
} catch (error) {
|
|
430
|
-
if (error instanceof SyntaxError) return {};
|
|
431
|
-
if (error instanceof Error && "code" in error) {
|
|
432
|
-
const { code } = error;
|
|
433
|
-
if (code === "EISDIR" || code === "EACCES") return {};
|
|
434
|
-
}
|
|
435
|
-
throw error;
|
|
436
|
-
}
|
|
437
|
-
};
|
|
438
|
-
const readPackageJson = (packageJsonPath) => {
|
|
439
|
-
const absolutePath = path.resolve(packageJsonPath);
|
|
440
|
-
const cached = cachedPackageJsons.get(absolutePath);
|
|
441
|
-
if (cached !== void 0) return cached;
|
|
442
|
-
const result = readPackageJsonUncached(absolutePath);
|
|
443
|
-
cachedPackageJsons.set(absolutePath, result);
|
|
444
|
-
return result;
|
|
445
|
-
};
|
|
446
|
-
//#endregion
|
|
447
|
-
//#region src/utils/check-reduced-motion.ts
|
|
448
|
-
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
|
|
449
|
-
const REDUCED_MOTION_FILE_GLOBS = [
|
|
450
|
-
"*.ts",
|
|
451
|
-
"*.tsx",
|
|
452
|
-
"*.js",
|
|
453
|
-
"*.jsx",
|
|
454
|
-
"*.css",
|
|
455
|
-
"*.scss"
|
|
456
|
-
];
|
|
457
|
-
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
458
|
-
filePath: "package.json",
|
|
459
|
-
plugin: "react-doctor",
|
|
460
|
-
rule: "require-reduced-motion",
|
|
461
|
-
severity: "error",
|
|
462
|
-
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
463
|
-
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
464
|
-
line: 0,
|
|
465
|
-
column: 0,
|
|
466
|
-
category: "Accessibility"
|
|
467
|
-
};
|
|
468
|
-
const checkReducedMotion = (rootDirectory) => {
|
|
469
|
-
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
470
|
-
if (!isFile(packageJsonPath)) return [];
|
|
471
|
-
let hasMotionLibrary = false;
|
|
472
|
-
try {
|
|
473
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
474
|
-
const allDependencies = {
|
|
475
|
-
...packageJson.dependencies,
|
|
476
|
-
...packageJson.devDependencies
|
|
477
|
-
};
|
|
478
|
-
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
479
|
-
} catch {
|
|
480
|
-
return [];
|
|
481
|
-
}
|
|
482
|
-
if (!hasMotionLibrary) return [];
|
|
483
|
-
const result = spawnSync("git", [
|
|
484
|
-
"grep",
|
|
485
|
-
"-ql",
|
|
486
|
-
"-E",
|
|
487
|
-
REDUCED_MOTION_GREP_PATTERN,
|
|
488
|
-
"--",
|
|
489
|
-
...REDUCED_MOTION_FILE_GLOBS
|
|
490
|
-
], {
|
|
491
|
-
cwd: rootDirectory,
|
|
492
|
-
stdio: [
|
|
493
|
-
"ignore",
|
|
494
|
-
"pipe",
|
|
495
|
-
"pipe"
|
|
496
|
-
]
|
|
497
|
-
});
|
|
498
|
-
if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
499
|
-
if (result.status === 0) return [];
|
|
500
|
-
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
501
|
-
};
|
|
502
|
-
//#endregion
|
|
503
|
-
//#region src/utils/parse-gitattributes-linguist.ts
|
|
504
|
-
const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
|
|
505
|
-
const FALSY_VALUES = new Set([
|
|
506
|
-
"false",
|
|
507
|
-
"0",
|
|
508
|
-
"off",
|
|
509
|
-
"no"
|
|
510
|
-
]);
|
|
511
|
-
const isTruthyLinguistAttribute = (token) => {
|
|
512
|
-
const match = LINGUIST_ATTRIBUTE_PATTERN.exec(token);
|
|
513
|
-
if (!match) return false;
|
|
514
|
-
if (match[1] === void 0) return true;
|
|
515
|
-
return !FALSY_VALUES.has(match[1].toLowerCase());
|
|
516
|
-
};
|
|
517
|
-
const parseGitattributesLinguistPaths = (filePath) => {
|
|
518
|
-
let content;
|
|
519
|
-
try {
|
|
520
|
-
content = fs.readFileSync(filePath, "utf-8");
|
|
521
|
-
} catch {
|
|
522
|
-
return [];
|
|
523
|
-
}
|
|
524
|
-
const paths = [];
|
|
525
|
-
for (const rawLine of content.split("\n")) {
|
|
526
|
-
const line = rawLine.trim();
|
|
527
|
-
if (line.length === 0 || line.startsWith("#")) continue;
|
|
528
|
-
const tokens = line.split(/\s+/);
|
|
529
|
-
if (tokens.length < 2) continue;
|
|
530
|
-
const [pathSpec, ...attributes] = tokens;
|
|
531
|
-
if (attributes.some(isTruthyLinguistAttribute)) paths.push(pathSpec);
|
|
532
|
-
}
|
|
533
|
-
return paths;
|
|
534
|
-
};
|
|
535
|
-
//#endregion
|
|
536
|
-
//#region src/utils/highlighter.ts
|
|
537
|
-
const highlighter = {
|
|
538
|
-
error: pc.red,
|
|
539
|
-
warn: pc.yellow,
|
|
540
|
-
info: pc.cyan,
|
|
541
|
-
success: pc.green,
|
|
542
|
-
dim: pc.dim,
|
|
543
|
-
gray: pc.gray,
|
|
544
|
-
bold: pc.bold
|
|
545
|
-
};
|
|
546
|
-
const logger = {
|
|
547
|
-
error(...args) {
|
|
548
|
-
console.error(highlighter.error(args.join(" ")));
|
|
549
|
-
},
|
|
550
|
-
warn(...args) {
|
|
551
|
-
console.warn(highlighter.warn(args.join(" ")));
|
|
552
|
-
},
|
|
553
|
-
info(...args) {
|
|
554
|
-
console.log(highlighter.info(args.join(" ")));
|
|
555
|
-
},
|
|
556
|
-
success(...args) {
|
|
557
|
-
console.log(highlighter.success(args.join(" ")));
|
|
558
|
-
},
|
|
559
|
-
dim(...args) {
|
|
560
|
-
console.log(highlighter.dim(args.join(" ")));
|
|
561
|
-
},
|
|
562
|
-
log(...args) {
|
|
563
|
-
console.log(args.join(" "));
|
|
564
|
-
},
|
|
565
|
-
break() {
|
|
566
|
-
console.log("");
|
|
567
|
-
}
|
|
568
|
-
};
|
|
569
|
-
//#endregion
|
|
570
|
-
//#region src/utils/read-ignore-file.ts
|
|
571
|
-
const stripGitignoreEscape = (pattern) => {
|
|
572
|
-
if (pattern.startsWith("\\#") || pattern.startsWith("\\!")) return pattern.slice(1);
|
|
573
|
-
return pattern;
|
|
574
|
-
};
|
|
575
|
-
const readIgnoreFile = (filePath) => {
|
|
576
|
-
let content;
|
|
577
|
-
try {
|
|
578
|
-
content = fs.readFileSync(filePath, "utf-8");
|
|
579
|
-
} catch (error) {
|
|
580
|
-
const errnoCode = error?.code;
|
|
581
|
-
if (errnoCode && errnoCode !== "ENOENT") logger.warn(`Could not read ignore file ${filePath}: ${errnoCode}`);
|
|
582
|
-
return [];
|
|
583
|
-
}
|
|
584
|
-
const patterns = [];
|
|
585
|
-
for (const line of content.split("\n")) {
|
|
586
|
-
const trimmed = line.trim();
|
|
587
|
-
if (trimmed.length === 0) continue;
|
|
588
|
-
if (trimmed.startsWith("#")) continue;
|
|
589
|
-
patterns.push(stripGitignoreEscape(trimmed));
|
|
590
|
-
}
|
|
591
|
-
return patterns;
|
|
592
|
-
};
|
|
593
|
-
//#endregion
|
|
594
|
-
//#region src/utils/collect-ignore-patterns.ts
|
|
595
|
-
const IGNORE_FILENAMES = [
|
|
596
|
-
".eslintignore",
|
|
597
|
-
".oxlintignore",
|
|
598
|
-
".prettierignore"
|
|
599
|
-
];
|
|
600
|
-
const cachedPatternsByRoot = /* @__PURE__ */ new Map();
|
|
601
|
-
const clearIgnorePatternsCache = () => {
|
|
602
|
-
cachedPatternsByRoot.clear();
|
|
603
|
-
};
|
|
604
|
-
const computeIgnorePatterns = (rootDirectory) => {
|
|
605
|
-
const seen = /* @__PURE__ */ new Set();
|
|
606
|
-
const patterns = [];
|
|
607
|
-
const addPattern = (pattern) => {
|
|
608
|
-
if (seen.has(pattern)) return;
|
|
609
|
-
seen.add(pattern);
|
|
610
|
-
patterns.push(pattern);
|
|
611
|
-
};
|
|
612
|
-
for (const filename of IGNORE_FILENAMES) for (const pattern of readIgnoreFile(path.join(rootDirectory, filename))) addPattern(pattern);
|
|
613
|
-
for (const linguistPath of parseGitattributesLinguistPaths(path.join(rootDirectory, ".gitattributes"))) addPattern(linguistPath);
|
|
614
|
-
return patterns;
|
|
615
|
-
};
|
|
616
|
-
const collectIgnorePatterns = (rootDirectory) => {
|
|
617
|
-
const cached = cachedPatternsByRoot.get(rootDirectory);
|
|
618
|
-
if (cached !== void 0) return cached;
|
|
619
|
-
const patterns = computeIgnorePatterns(rootDirectory);
|
|
620
|
-
cachedPatternsByRoot.set(rootDirectory, patterns);
|
|
621
|
-
return patterns;
|
|
622
|
-
};
|
|
623
|
-
//#endregion
|
|
624
|
-
//#region src/utils/find-monorepo-root.ts
|
|
625
|
-
const isMonorepoRoot = (directory) => {
|
|
626
|
-
if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
|
|
627
|
-
if (isFile(path.join(directory, "nx.json"))) return true;
|
|
628
|
-
const packageJsonPath = path.join(directory, "package.json");
|
|
629
|
-
if (!isFile(packageJsonPath)) return false;
|
|
630
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
631
|
-
return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
|
|
632
|
-
};
|
|
633
|
-
const findMonorepoRoot = (startDirectory) => {
|
|
634
|
-
let currentDirectory = path.dirname(startDirectory);
|
|
635
|
-
while (currentDirectory !== path.dirname(currentDirectory)) {
|
|
636
|
-
if (isMonorepoRoot(currentDirectory)) return currentDirectory;
|
|
637
|
-
currentDirectory = path.dirname(currentDirectory);
|
|
638
|
-
}
|
|
639
|
-
return null;
|
|
640
|
-
};
|
|
641
|
-
//#endregion
|
|
642
|
-
//#region src/utils/is-plain-object.ts
|
|
643
|
-
const isPlainObject = (value) => {
|
|
644
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
645
|
-
const prototype = Object.getPrototypeOf(value);
|
|
646
|
-
return prototype === null || prototype === Object.prototype;
|
|
647
|
-
};
|
|
648
|
-
//#endregion
|
|
649
|
-
//#region src/utils/discover-project.ts
|
|
650
|
-
const REACT_COMPILER_PACKAGES = new Set([
|
|
651
|
-
"babel-plugin-react-compiler",
|
|
652
|
-
"react-compiler-runtime",
|
|
653
|
-
"eslint-plugin-react-compiler"
|
|
654
|
-
]);
|
|
655
|
-
const TANSTACK_QUERY_PACKAGES = new Set([
|
|
656
|
-
"@tanstack/react-query",
|
|
657
|
-
"@tanstack/query-core",
|
|
658
|
-
"react-query"
|
|
659
|
-
]);
|
|
660
|
-
const NEXT_CONFIG_FILENAMES = [
|
|
661
|
-
"next.config.js",
|
|
662
|
-
"next.config.mjs",
|
|
663
|
-
"next.config.ts",
|
|
664
|
-
"next.config.cjs"
|
|
665
|
-
];
|
|
666
|
-
const BABEL_CONFIG_FILENAMES = [
|
|
667
|
-
".babelrc",
|
|
668
|
-
".babelrc.json",
|
|
669
|
-
"babel.config.js",
|
|
670
|
-
"babel.config.json",
|
|
671
|
-
"babel.config.cjs",
|
|
672
|
-
"babel.config.mjs"
|
|
673
|
-
];
|
|
674
|
-
const VITE_CONFIG_FILENAMES = [
|
|
675
|
-
"vite.config.js",
|
|
676
|
-
"vite.config.ts",
|
|
677
|
-
"vite.config.mjs",
|
|
678
|
-
"vite.config.mts",
|
|
679
|
-
"vite.config.cjs",
|
|
680
|
-
"vite.config.cts",
|
|
681
|
-
"vitest.config.ts",
|
|
682
|
-
"vitest.config.js"
|
|
683
|
-
];
|
|
684
|
-
const EXPO_APP_CONFIG_FILENAMES = [
|
|
685
|
-
"app.json",
|
|
686
|
-
"app.config.js",
|
|
687
|
-
"app.config.ts"
|
|
688
|
-
];
|
|
689
|
-
const REACT_COMPILER_PACKAGE_REFERENCE_PATTERN = /babel-plugin-react-compiler|react-compiler-runtime|eslint-plugin-react-compiler|["']react-compiler["']/;
|
|
690
|
-
const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*(?:true\b|\{)/;
|
|
691
|
-
const FRAMEWORK_PACKAGES = {
|
|
692
|
-
next: "nextjs",
|
|
693
|
-
"@tanstack/react-start": "tanstack-start",
|
|
694
|
-
vite: "vite",
|
|
695
|
-
"react-scripts": "cra",
|
|
696
|
-
"@remix-run/react": "remix",
|
|
697
|
-
gatsby: "gatsby",
|
|
698
|
-
expo: "expo",
|
|
699
|
-
"react-native": "react-native"
|
|
700
|
-
};
|
|
701
|
-
const countSourceFilesViaFilesystem = (rootDirectory) => {
|
|
702
|
-
let count = 0;
|
|
703
|
-
const stack = [rootDirectory];
|
|
704
|
-
while (stack.length > 0) {
|
|
705
|
-
const currentDirectory = stack.pop();
|
|
706
|
-
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
|
|
707
|
-
for (const entry of entries) {
|
|
708
|
-
if (entry.isDirectory()) {
|
|
709
|
-
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
|
|
710
|
-
continue;
|
|
711
|
-
}
|
|
712
|
-
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
return count;
|
|
716
|
-
};
|
|
717
|
-
const countSourceFilesViaGit = (rootDirectory) => {
|
|
718
|
-
const result = spawnSync("git", [
|
|
719
|
-
"ls-files",
|
|
720
|
-
"-z",
|
|
721
|
-
"--cached",
|
|
722
|
-
"--others",
|
|
723
|
-
"--exclude-standard"
|
|
724
|
-
], {
|
|
725
|
-
cwd: rootDirectory,
|
|
726
|
-
encoding: "utf-8",
|
|
727
|
-
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
728
|
-
});
|
|
729
|
-
if (result.error || result.status !== 0) return null;
|
|
730
|
-
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
|
|
731
|
-
};
|
|
732
|
-
const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
|
|
733
|
-
const collectAllDependencies = (packageJson) => ({
|
|
734
|
-
...packageJson.peerDependencies,
|
|
735
|
-
...packageJson.dependencies,
|
|
736
|
-
...packageJson.devDependencies
|
|
737
|
-
});
|
|
738
|
-
const detectFramework = (dependencies) => {
|
|
739
|
-
for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
|
|
740
|
-
return "unknown";
|
|
741
|
-
};
|
|
742
|
-
const isCatalogReference = (version) => version.startsWith("catalog:");
|
|
743
|
-
const extractCatalogName = (version) => {
|
|
744
|
-
if (!isCatalogReference(version)) return null;
|
|
745
|
-
const name = version.slice(8).trim();
|
|
746
|
-
return name.length > 0 ? name : null;
|
|
747
|
-
};
|
|
748
|
-
const resolveVersionFromCatalog = (catalog, packageName) => {
|
|
749
|
-
const version = catalog[packageName];
|
|
750
|
-
if (typeof version === "string" && !isCatalogReference(version)) return version;
|
|
751
|
-
return null;
|
|
752
|
-
};
|
|
753
|
-
const parsePnpmWorkspaceCatalogs = (rootDirectory) => {
|
|
754
|
-
const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
|
|
755
|
-
if (!isFile(workspacePath)) return {
|
|
756
|
-
defaultCatalog: {},
|
|
757
|
-
namedCatalogs: {}
|
|
758
|
-
};
|
|
759
|
-
const content = fs.readFileSync(workspacePath, "utf-8");
|
|
760
|
-
const defaultCatalog = {};
|
|
761
|
-
const namedCatalogs = {};
|
|
762
|
-
let currentSection = "none";
|
|
763
|
-
let currentCatalogName = "";
|
|
764
|
-
for (const line of content.split("\n")) {
|
|
765
|
-
const trimmed = line.trim();
|
|
766
|
-
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
|
767
|
-
const indentLevel = line.search(/\S/);
|
|
768
|
-
if (indentLevel === 0 && trimmed === "catalog:") {
|
|
769
|
-
currentSection = "catalog";
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
772
|
-
if (indentLevel === 0 && trimmed === "catalogs:") {
|
|
773
|
-
currentSection = "catalogs";
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
if (indentLevel === 0) {
|
|
777
|
-
currentSection = "none";
|
|
778
|
-
continue;
|
|
779
|
-
}
|
|
780
|
-
if (currentSection === "catalog" && indentLevel > 0) {
|
|
781
|
-
const colonIndex = trimmed.indexOf(":");
|
|
782
|
-
if (colonIndex > 0) {
|
|
783
|
-
const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
|
|
784
|
-
const value = trimmed.slice(colonIndex + 1).trim().replace(/["']/g, "");
|
|
785
|
-
if (key && value) defaultCatalog[key] = value;
|
|
786
|
-
}
|
|
787
|
-
continue;
|
|
788
|
-
}
|
|
789
|
-
if (currentSection === "catalogs" && indentLevel > 0) {
|
|
790
|
-
if (trimmed.endsWith(":") && !trimmed.includes(" ")) {
|
|
791
|
-
currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
|
|
792
|
-
currentSection = "named-catalog";
|
|
793
|
-
namedCatalogs[currentCatalogName] = {};
|
|
794
|
-
continue;
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
if (currentSection === "named-catalog" && indentLevel > 0) {
|
|
798
|
-
if (indentLevel <= 2 && trimmed.endsWith(":") && !trimmed.includes(" ")) {
|
|
799
|
-
currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
|
|
800
|
-
namedCatalogs[currentCatalogName] = {};
|
|
801
|
-
continue;
|
|
802
|
-
}
|
|
803
|
-
const colonIndex = trimmed.indexOf(":");
|
|
804
|
-
if (colonIndex > 0 && currentCatalogName) {
|
|
805
|
-
const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
|
|
806
|
-
const value = trimmed.slice(colonIndex + 1).trim().replace(/["']/g, "");
|
|
807
|
-
if (key && value) namedCatalogs[currentCatalogName][key] = value;
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
return {
|
|
812
|
-
defaultCatalog,
|
|
813
|
-
namedCatalogs
|
|
814
|
-
};
|
|
815
|
-
};
|
|
816
|
-
const resolveCatalogVersionFromCollection = (catalogs, packageName, catalogReference) => {
|
|
817
|
-
if (catalogReference) {
|
|
818
|
-
const namedCatalog = catalogs.namedCatalogs[catalogReference];
|
|
819
|
-
if (namedCatalog?.[packageName]) return namedCatalog[packageName];
|
|
820
|
-
}
|
|
821
|
-
if (catalogs.defaultCatalog[packageName]) return catalogs.defaultCatalog[packageName];
|
|
822
|
-
for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
|
|
823
|
-
return null;
|
|
824
|
-
};
|
|
825
|
-
const resolveCatalogVersion = (packageJson, packageName, rootDirectory, explicitCatalogReference) => {
|
|
826
|
-
const rawVersion = collectAllDependencies(packageJson)[packageName];
|
|
827
|
-
const catalogName = explicitCatalogReference ?? (rawVersion ? extractCatalogName(rawVersion) : null);
|
|
828
|
-
if (isPlainObject(packageJson.catalog)) {
|
|
829
|
-
const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
|
|
830
|
-
if (version) return version;
|
|
831
|
-
}
|
|
832
|
-
if (isPlainObject(packageJson.catalogs)) {
|
|
833
|
-
const namedCatalog = catalogName ? packageJson.catalogs[catalogName] : void 0;
|
|
834
|
-
if (namedCatalog && isPlainObject(namedCatalog)) {
|
|
835
|
-
const version = resolveVersionFromCatalog(namedCatalog, packageName);
|
|
836
|
-
if (version) return version;
|
|
837
|
-
}
|
|
838
|
-
for (const catalogEntries of Object.values(packageJson.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
839
|
-
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
840
|
-
if (version) return version;
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
const workspaces = packageJson.workspaces;
|
|
844
|
-
if (workspaces && !Array.isArray(workspaces)) {
|
|
845
|
-
if (isPlainObject(workspaces.catalog)) {
|
|
846
|
-
const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
|
|
847
|
-
if (version) return version;
|
|
848
|
-
}
|
|
849
|
-
if (isPlainObject(workspaces.catalogs)) {
|
|
850
|
-
const namedCatalog = catalogName ? workspaces.catalogs[catalogName] : void 0;
|
|
851
|
-
if (namedCatalog && isPlainObject(namedCatalog)) {
|
|
852
|
-
const version = resolveVersionFromCatalog(namedCatalog, packageName);
|
|
853
|
-
if (version) return version;
|
|
854
|
-
}
|
|
855
|
-
for (const catalogEntries of Object.values(workspaces.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
856
|
-
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
857
|
-
if (version) return version;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
if (rootDirectory) {
|
|
862
|
-
const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, catalogName);
|
|
863
|
-
if (pnpmVersion) return pnpmVersion;
|
|
864
|
-
}
|
|
865
|
-
return null;
|
|
866
|
-
};
|
|
867
|
-
const extractDependencyInfo = (packageJson) => {
|
|
868
|
-
const allDependencies = collectAllDependencies(packageJson);
|
|
869
|
-
const rawVersion = allDependencies.react ?? null;
|
|
870
|
-
return {
|
|
871
|
-
reactVersion: rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null,
|
|
872
|
-
framework: detectFramework(allDependencies)
|
|
873
|
-
};
|
|
874
|
-
};
|
|
875
|
-
const parsePnpmWorkspacePatterns = (rootDirectory) => {
|
|
876
|
-
const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
|
|
877
|
-
if (!isFile(workspacePath)) return [];
|
|
878
|
-
const content = fs.readFileSync(workspacePath, "utf-8");
|
|
879
|
-
const patterns = [];
|
|
880
|
-
let isInsidePackagesBlock = false;
|
|
881
|
-
for (const line of content.split("\n")) {
|
|
882
|
-
const trimmed = line.trim();
|
|
883
|
-
if (trimmed === "packages:") {
|
|
884
|
-
isInsidePackagesBlock = true;
|
|
885
|
-
continue;
|
|
886
|
-
}
|
|
887
|
-
if (isInsidePackagesBlock && trimmed.startsWith("-")) patterns.push(trimmed.replace(/^-\s*/, "").replace(/["']/g, ""));
|
|
888
|
-
else if (isInsidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith("#")) isInsidePackagesBlock = false;
|
|
889
|
-
}
|
|
890
|
-
return patterns;
|
|
891
|
-
};
|
|
892
|
-
const NX_PROJECT_DISCOVERY_DIRS = [
|
|
893
|
-
"apps",
|
|
894
|
-
"libs",
|
|
895
|
-
"packages"
|
|
896
|
-
];
|
|
897
|
-
const getNxWorkspaceDirectories = (rootDirectory) => {
|
|
898
|
-
if (!isFile(path.join(rootDirectory, "nx.json"))) return [];
|
|
899
|
-
const collected = [];
|
|
900
|
-
for (const candidate of NX_PROJECT_DISCOVERY_DIRS) {
|
|
901
|
-
const candidatePath = path.join(rootDirectory, candidate);
|
|
902
|
-
if (!fs.existsSync(candidatePath) || !fs.statSync(candidatePath).isDirectory()) continue;
|
|
903
|
-
for (const entry of fs.readdirSync(candidatePath, { withFileTypes: true })) {
|
|
904
|
-
if (!entry.isDirectory()) continue;
|
|
905
|
-
const projectDirectory = path.join(candidatePath, entry.name);
|
|
906
|
-
if (isFile(path.join(projectDirectory, "project.json")) || isFile(path.join(projectDirectory, "package.json"))) collected.push(`${candidate}/${entry.name}`);
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
return collected;
|
|
910
|
-
};
|
|
911
|
-
const getWorkspacePatterns = (rootDirectory, packageJson) => {
|
|
912
|
-
const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory);
|
|
913
|
-
if (pnpmPatterns.length > 0) return pnpmPatterns;
|
|
914
|
-
if (Array.isArray(packageJson.workspaces)) return packageJson.workspaces;
|
|
915
|
-
if (packageJson.workspaces?.packages) return packageJson.workspaces.packages;
|
|
916
|
-
const nxPatterns = getNxWorkspaceDirectories(rootDirectory);
|
|
917
|
-
if (nxPatterns.length > 0) return nxPatterns;
|
|
918
|
-
return [];
|
|
919
|
-
};
|
|
920
|
-
const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
|
|
921
|
-
const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
|
|
922
|
-
if (!cleanPattern.includes("*")) {
|
|
923
|
-
const directoryPath = path.join(rootDirectory, cleanPattern);
|
|
924
|
-
if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
|
|
925
|
-
return [];
|
|
926
|
-
}
|
|
927
|
-
const wildcardIndex = cleanPattern.indexOf("*");
|
|
928
|
-
const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
|
|
929
|
-
const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
|
|
930
|
-
if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
|
|
931
|
-
return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry, suffixAfterWildcard)).filter((entryPath) => fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() && isFile(path.join(entryPath, "package.json")));
|
|
932
|
-
};
|
|
933
|
-
const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
934
|
-
const monorepoRoot = findMonorepoRoot(directory);
|
|
935
|
-
if (!monorepoRoot) return {
|
|
936
|
-
reactVersion: null,
|
|
937
|
-
framework: "unknown"
|
|
938
|
-
};
|
|
939
|
-
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
940
|
-
if (!isFile(monorepoPackageJsonPath)) return {
|
|
941
|
-
reactVersion: null,
|
|
942
|
-
framework: "unknown"
|
|
943
|
-
};
|
|
944
|
-
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
945
|
-
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
946
|
-
const leafPackageJsonPath = path.join(directory, "package.json");
|
|
947
|
-
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, isFile(leafPackageJsonPath) ? extractCatalogName(collectAllDependencies(readPackageJson(leafPackageJsonPath)).react ?? "") ?? null : null);
|
|
948
|
-
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
949
|
-
return {
|
|
950
|
-
reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
|
|
951
|
-
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
952
|
-
};
|
|
953
|
-
};
|
|
954
|
-
const findReactInWorkspaces = (rootDirectory, packageJson) => {
|
|
955
|
-
const patterns = getWorkspacePatterns(rootDirectory, packageJson);
|
|
956
|
-
const result = {
|
|
957
|
-
reactVersion: null,
|
|
958
|
-
framework: "unknown"
|
|
959
|
-
};
|
|
960
|
-
for (const pattern of patterns) {
|
|
961
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
962
|
-
for (const workspaceDirectory of directories) {
|
|
963
|
-
const info = extractDependencyInfo(readPackageJson(path.join(workspaceDirectory, "package.json")));
|
|
964
|
-
if (info.reactVersion && !result.reactVersion) result.reactVersion = info.reactVersion;
|
|
965
|
-
if (info.framework !== "unknown" && result.framework === "unknown") result.framework = info.framework;
|
|
966
|
-
if (result.reactVersion && result.framework !== "unknown") return result;
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
return result;
|
|
970
|
-
};
|
|
971
|
-
const REACT_DEPENDENCY_NAMES = new Set([
|
|
972
|
-
"react",
|
|
973
|
-
"react-native",
|
|
974
|
-
"next"
|
|
975
|
-
]);
|
|
976
|
-
const hasReactDependency = (packageJson) => {
|
|
977
|
-
const allDependencies = collectAllDependencies(packageJson);
|
|
978
|
-
return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
|
|
979
|
-
};
|
|
980
|
-
const toReactWorkspacePackages = (directories) => {
|
|
981
|
-
const packages = [];
|
|
982
|
-
for (const directory of directories) {
|
|
983
|
-
const packageJsonPath = path.join(directory, "package.json");
|
|
984
|
-
if (!isFile(packageJsonPath)) continue;
|
|
985
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
986
|
-
if (!hasReactDependency(packageJson)) continue;
|
|
987
|
-
const name = packageJson.name ?? path.basename(directory);
|
|
988
|
-
packages.push({
|
|
989
|
-
name,
|
|
990
|
-
directory
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
return packages;
|
|
994
|
-
};
|
|
995
|
-
const listManifestWorkspacePackages = (rootDirectory) => {
|
|
996
|
-
if (isFile(path.join(rootDirectory, "package.json"))) return listWorkspacePackages(rootDirectory);
|
|
997
|
-
const patterns = parsePnpmWorkspacePatterns(rootDirectory);
|
|
998
|
-
const nxPatterns = patterns.length > 0 ? [] : getNxWorkspaceDirectories(rootDirectory);
|
|
999
|
-
return toReactWorkspacePackages((patterns.length > 0 ? patterns : nxPatterns).flatMap((pattern) => resolveWorkspaceDirectories(rootDirectory, pattern)));
|
|
1000
|
-
};
|
|
1001
|
-
const discoverReactSubprojectsByFilesystem = (rootDirectory) => {
|
|
1002
|
-
const packages = [];
|
|
1003
|
-
const pendingDirectories = [rootDirectory];
|
|
1004
|
-
while (pendingDirectories.length > 0) {
|
|
1005
|
-
const currentDirectory = pendingDirectories.shift();
|
|
1006
|
-
if (!currentDirectory) continue;
|
|
1007
|
-
const packageJsonPath = path.join(currentDirectory, "package.json");
|
|
1008
|
-
if (isFile(packageJsonPath)) {
|
|
1009
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
1010
|
-
if (hasReactDependency(packageJson)) {
|
|
1011
|
-
const name = packageJson.name ?? path.basename(currentDirectory);
|
|
1012
|
-
packages.push({
|
|
1013
|
-
name,
|
|
1014
|
-
directory: currentDirectory
|
|
1015
|
-
});
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true }).toSorted((firstEntry, secondEntry) => firstEntry.name.localeCompare(secondEntry.name));
|
|
1019
|
-
for (const entry of entries) {
|
|
1020
|
-
if (!entry.isDirectory() || entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
1021
|
-
pendingDirectories.push(path.join(currentDirectory, entry.name));
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
return packages;
|
|
1025
|
-
};
|
|
1026
|
-
const discoverReactSubprojects = (rootDirectory) => {
|
|
1027
|
-
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
1028
|
-
const manifestPackages = listManifestWorkspacePackages(rootDirectory);
|
|
1029
|
-
if (manifestPackages.length > 0) return manifestPackages;
|
|
1030
|
-
return discoverReactSubprojectsByFilesystem(rootDirectory);
|
|
1031
|
-
};
|
|
1032
|
-
const listWorkspacePackages = (rootDirectory) => {
|
|
1033
|
-
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
1034
|
-
if (!isFile(packageJsonPath)) return [];
|
|
1035
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
1036
|
-
const patterns = getWorkspacePatterns(rootDirectory, packageJson);
|
|
1037
|
-
if (patterns.length === 0) return [];
|
|
1038
|
-
const packages = [];
|
|
1039
|
-
if (hasReactDependency(packageJson)) {
|
|
1040
|
-
const rootName = packageJson.name ?? path.basename(rootDirectory);
|
|
1041
|
-
packages.push({
|
|
1042
|
-
name: rootName,
|
|
1043
|
-
directory: rootDirectory
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
for (const pattern of patterns) {
|
|
1047
|
-
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
1048
|
-
for (const workspaceDirectory of directories) {
|
|
1049
|
-
const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, "package.json"));
|
|
1050
|
-
if (!hasReactDependency(workspacePackageJson)) continue;
|
|
1051
|
-
const name = workspacePackageJson.name ?? path.basename(workspaceDirectory);
|
|
1052
|
-
packages.push({
|
|
1053
|
-
name,
|
|
1054
|
-
directory: workspaceDirectory
|
|
1055
|
-
});
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
return packages;
|
|
1059
|
-
};
|
|
1060
|
-
const hasCompilerPackage = (packageJson) => {
|
|
1061
|
-
const allDependencies = collectAllDependencies(packageJson);
|
|
1062
|
-
return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
|
|
1063
|
-
};
|
|
1064
|
-
const hasCompilerInConfigFile = (filePath) => {
|
|
1065
|
-
if (!isFile(filePath)) return false;
|
|
1066
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
1067
|
-
return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
|
|
1068
|
-
};
|
|
1069
|
-
const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
|
|
1070
|
-
const isProjectBoundary$2 = (directory) => {
|
|
1071
|
-
if (fs.existsSync(path.join(directory, ".git"))) return true;
|
|
1072
|
-
return isMonorepoRoot(directory);
|
|
1073
|
-
};
|
|
1074
|
-
const detectReactCompiler = (directory, packageJson) => {
|
|
1075
|
-
if (hasCompilerPackage(packageJson)) return true;
|
|
1076
|
-
if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
|
|
1077
|
-
if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
|
|
1078
|
-
if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
|
|
1079
|
-
if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
|
|
1080
|
-
if (isProjectBoundary$2(directory)) return false;
|
|
1081
|
-
let ancestorDirectory = path.dirname(directory);
|
|
1082
|
-
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
1083
|
-
const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
|
|
1084
|
-
if (isFile(ancestorPackagePath)) {
|
|
1085
|
-
if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
|
|
1086
|
-
}
|
|
1087
|
-
if (isProjectBoundary$2(ancestorDirectory)) return false;
|
|
1088
|
-
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
1089
|
-
}
|
|
1090
|
-
return false;
|
|
1091
|
-
};
|
|
1092
|
-
const cachedProjectInfos = /* @__PURE__ */ new Map();
|
|
1093
|
-
const clearProjectCache = () => {
|
|
1094
|
-
cachedProjectInfos.clear();
|
|
1095
|
-
};
|
|
1096
|
-
const discoverProject = (directory) => {
|
|
1097
|
-
const cached = cachedProjectInfos.get(directory);
|
|
1098
|
-
if (cached !== void 0) return cached;
|
|
1099
|
-
const packageJsonPath = path.join(directory, "package.json");
|
|
1100
|
-
if (!isFile(packageJsonPath)) throw new PackageJsonNotFoundError(directory);
|
|
1101
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
1102
|
-
let { reactVersion, framework } = extractDependencyInfo(packageJson);
|
|
1103
|
-
const leafCatalogReference = extractCatalogName(collectAllDependencies(packageJson).react ?? "") ?? null;
|
|
1104
|
-
if (!reactVersion) reactVersion = resolveCatalogVersion(packageJson, "react", directory, leafCatalogReference);
|
|
1105
|
-
if (!reactVersion) {
|
|
1106
|
-
const monorepoRoot = findMonorepoRoot(directory);
|
|
1107
|
-
if (monorepoRoot) {
|
|
1108
|
-
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
1109
|
-
if (isFile(monorepoPackageJsonPath)) reactVersion = resolveCatalogVersion(readPackageJson(monorepoPackageJsonPath), "react", monorepoRoot, leafCatalogReference);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
if (!reactVersion || framework === "unknown") {
|
|
1113
|
-
const workspaceInfo = findReactInWorkspaces(directory, packageJson);
|
|
1114
|
-
if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
|
|
1115
|
-
if (framework === "unknown" && workspaceInfo.framework !== "unknown") framework = workspaceInfo.framework;
|
|
1116
|
-
}
|
|
1117
|
-
if ((!reactVersion || framework === "unknown") && !isMonorepoRoot(directory)) {
|
|
1118
|
-
const monorepoInfo = findDependencyInfoFromMonorepoRoot(directory);
|
|
1119
|
-
if (!reactVersion) reactVersion = monorepoInfo.reactVersion;
|
|
1120
|
-
if (framework === "unknown") framework = monorepoInfo.framework;
|
|
1121
|
-
}
|
|
1122
|
-
const projectName = packageJson.name ?? path.basename(directory);
|
|
1123
|
-
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
1124
|
-
const sourceFileCount = countSourceFiles(directory);
|
|
1125
|
-
const hasReactCompiler = detectReactCompiler(directory, packageJson);
|
|
1126
|
-
const allDependencies = collectAllDependencies(packageJson);
|
|
1127
|
-
const hasTanStackQuery = Object.keys(allDependencies).some((packageName) => TANSTACK_QUERY_PACKAGES.has(packageName));
|
|
1128
|
-
const projectInfo = {
|
|
1129
|
-
rootDirectory: directory,
|
|
1130
|
-
projectName,
|
|
1131
|
-
reactVersion,
|
|
1132
|
-
framework,
|
|
1133
|
-
hasTypeScript,
|
|
1134
|
-
hasReactCompiler,
|
|
1135
|
-
hasTanStackQuery,
|
|
1136
|
-
sourceFileCount
|
|
1137
|
-
};
|
|
1138
|
-
cachedProjectInfos.set(directory, projectInfo);
|
|
1139
|
-
return projectInfo;
|
|
1140
|
-
};
|
|
1141
|
-
//#endregion
|
|
1142
|
-
//#region src/utils/jsx-include-paths.ts
|
|
1143
|
-
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
1144
|
-
//#endregion
|
|
1145
|
-
//#region src/utils/validate-config-types.ts
|
|
1146
|
-
const BOOLEAN_FIELD_NAMES = [
|
|
1147
|
-
"lint",
|
|
1148
|
-
"deadCode",
|
|
1149
|
-
"verbose",
|
|
1150
|
-
"customRulesOnly",
|
|
1151
|
-
"share",
|
|
1152
|
-
"respectInlineDisables",
|
|
1153
|
-
"adoptExistingLintConfig"
|
|
1154
|
-
];
|
|
1155
|
-
const STRING_FIELD_NAMES = ["rootDir"];
|
|
1156
|
-
const warnConfigField$1 = (message) => {
|
|
1157
|
-
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1158
|
-
};
|
|
1159
|
-
const coerceMaybeBooleanString = (fieldName, value) => {
|
|
1160
|
-
if (typeof value === "boolean" || value === void 0) return value;
|
|
1161
|
-
if (value === "true") {
|
|
1162
|
-
warnConfigField$1(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
|
|
1163
|
-
return true;
|
|
1164
|
-
}
|
|
1165
|
-
if (value === "false") {
|
|
1166
|
-
warnConfigField$1(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
|
|
1167
|
-
return false;
|
|
1168
|
-
}
|
|
1169
|
-
warnConfigField$1(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1170
|
-
};
|
|
1171
|
-
const validateString = (fieldName, value) => {
|
|
1172
|
-
if (typeof value === "string") return value;
|
|
1173
|
-
warnConfigField$1(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
|
|
1174
|
-
};
|
|
1175
|
-
const validateConfigTypes = (config) => {
|
|
1176
|
-
const validated = { ...config };
|
|
1177
|
-
for (const fieldName of BOOLEAN_FIELD_NAMES) {
|
|
1178
|
-
const original = config[fieldName];
|
|
1179
|
-
if (original === void 0) continue;
|
|
1180
|
-
const coerced = coerceMaybeBooleanString(fieldName, original);
|
|
1181
|
-
if (coerced === void 0) delete validated[fieldName];
|
|
1182
|
-
else validated[fieldName] = coerced;
|
|
1183
|
-
}
|
|
1184
|
-
for (const fieldName of STRING_FIELD_NAMES) {
|
|
1185
|
-
const original = config[fieldName];
|
|
1186
|
-
if (original === void 0) continue;
|
|
1187
|
-
const validatedString = validateString(fieldName, original);
|
|
1188
|
-
if (validatedString === void 0) delete validated[fieldName];
|
|
1189
|
-
else validated[fieldName] = validatedString;
|
|
1190
|
-
}
|
|
1191
|
-
return validated;
|
|
1192
|
-
};
|
|
1193
|
-
//#endregion
|
|
1194
|
-
//#region src/utils/load-config.ts
|
|
1195
|
-
const CONFIG_FILENAME = "react-doctor.config.json";
|
|
1196
|
-
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
1197
|
-
const loadConfigFromDirectory = (directory) => {
|
|
1198
|
-
const configFilePath = path.join(directory, CONFIG_FILENAME);
|
|
1199
|
-
if (isFile(configFilePath)) try {
|
|
1200
|
-
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
1201
|
-
const parsed = JSON.parse(fileContent);
|
|
1202
|
-
if (isPlainObject(parsed)) return {
|
|
1203
|
-
config: validateConfigTypes(parsed),
|
|
1204
|
-
sourceDirectory: directory
|
|
1205
|
-
};
|
|
1206
|
-
logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
1207
|
-
} catch (error) {
|
|
1208
|
-
logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1209
|
-
}
|
|
1210
|
-
const packageJsonPath = path.join(directory, "package.json");
|
|
1211
|
-
if (isFile(packageJsonPath)) try {
|
|
1212
|
-
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
1213
|
-
const packageJson = JSON.parse(fileContent);
|
|
1214
|
-
if (isPlainObject(packageJson)) {
|
|
1215
|
-
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
1216
|
-
if (isPlainObject(embeddedConfig)) return {
|
|
1217
|
-
config: validateConfigTypes(embeddedConfig),
|
|
1218
|
-
sourceDirectory: directory
|
|
1219
|
-
};
|
|
1220
|
-
}
|
|
1221
|
-
} catch {
|
|
1222
|
-
return null;
|
|
1223
|
-
}
|
|
1224
|
-
return null;
|
|
1225
|
-
};
|
|
1226
|
-
const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1227
|
-
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
1228
|
-
const clearConfigCache = () => {
|
|
1229
|
-
cachedConfigs.clear();
|
|
1230
|
-
};
|
|
1231
|
-
const loadConfigWithSource = (rootDirectory) => {
|
|
1232
|
-
const cached = cachedConfigs.get(rootDirectory);
|
|
1233
|
-
if (cached !== void 0) return cached;
|
|
1234
|
-
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
1235
|
-
if (localConfig) {
|
|
1236
|
-
cachedConfigs.set(rootDirectory, localConfig);
|
|
1237
|
-
return localConfig;
|
|
1238
|
-
}
|
|
1239
|
-
if (isProjectBoundary$1(rootDirectory)) {
|
|
1240
|
-
cachedConfigs.set(rootDirectory, null);
|
|
1241
|
-
return null;
|
|
1242
|
-
}
|
|
1243
|
-
let ancestorDirectory = path.dirname(rootDirectory);
|
|
1244
|
-
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
1245
|
-
const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
|
|
1246
|
-
if (ancestorConfig) {
|
|
1247
|
-
cachedConfigs.set(rootDirectory, ancestorConfig);
|
|
1248
|
-
return ancestorConfig;
|
|
1249
|
-
}
|
|
1250
|
-
if (isProjectBoundary$1(ancestorDirectory)) {
|
|
1251
|
-
cachedConfigs.set(rootDirectory, null);
|
|
1252
|
-
return null;
|
|
1253
|
-
}
|
|
1254
|
-
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
1255
|
-
}
|
|
1256
|
-
cachedConfigs.set(rootDirectory, null);
|
|
1257
|
-
return null;
|
|
1258
|
-
};
|
|
1259
|
-
//#endregion
|
|
1260
|
-
//#region src/utils/match-glob-pattern.ts
|
|
1261
|
-
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
1262
|
-
const compileGlobPattern = (pattern) => {
|
|
1263
|
-
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
|
|
1264
|
-
let regexSource = "^";
|
|
1265
|
-
let characterIndex = 0;
|
|
1266
|
-
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
1267
|
-
regexSource += "(?:.+/)?";
|
|
1268
|
-
characterIndex += 3;
|
|
1269
|
-
} else {
|
|
1270
|
-
regexSource += ".*";
|
|
1271
|
-
characterIndex += 2;
|
|
1272
|
-
}
|
|
1273
|
-
else if (normalizedPattern[characterIndex] === "*") {
|
|
1274
|
-
regexSource += "[^/]*";
|
|
1275
|
-
characterIndex++;
|
|
1276
|
-
} else if (normalizedPattern[characterIndex] === "?") {
|
|
1277
|
-
regexSource += "[^/]";
|
|
1278
|
-
characterIndex++;
|
|
1279
|
-
} else {
|
|
1280
|
-
regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
|
|
1281
|
-
characterIndex++;
|
|
1282
|
-
}
|
|
1283
|
-
regexSource += "$";
|
|
1284
|
-
return new RegExp(regexSource);
|
|
1285
|
-
};
|
|
1286
|
-
//#endregion
|
|
1287
|
-
//#region src/utils/to-relative-path.ts
|
|
1288
|
-
const toRelativePath = (filePath, rootDirectory) => {
|
|
1289
|
-
const normalizedFilePath = filePath.replace(/\\/g, "/");
|
|
1290
|
-
const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
|
|
1291
|
-
if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
|
|
1292
|
-
return normalizedFilePath.replace(/^\.\//, "");
|
|
1293
|
-
};
|
|
1294
|
-
//#endregion
|
|
1295
|
-
//#region src/utils/apply-ignore-overrides.ts
|
|
1296
|
-
const warnConfigField = (message) => {
|
|
1297
|
-
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1298
|
-
};
|
|
1299
|
-
const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
1300
|
-
const collectStringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1301
|
-
const validateOverrideEntry = (entry, index) => {
|
|
1302
|
-
if (!isPlainObject(entry)) {
|
|
1303
|
-
warnConfigField(`ignore.overrides[${index}] must be an object with { files, rules }; ignoring this entry.`);
|
|
1304
|
-
return null;
|
|
1305
|
-
}
|
|
1306
|
-
if (!isStringArray(entry.files)) {
|
|
1307
|
-
warnConfigField(`ignore.overrides[${index}].files must be an array of strings; ignoring this entry.`);
|
|
1308
|
-
return null;
|
|
1309
|
-
}
|
|
1310
|
-
if (entry.rules !== void 0 && !isStringArray(entry.rules)) {
|
|
1311
|
-
warnConfigField(`ignore.overrides[${index}].rules must be an array of "plugin/rule" strings or omitted; treating as missing (override would suppress every rule for the matched files).`);
|
|
1312
|
-
return { files: entry.files };
|
|
1313
|
-
}
|
|
1314
|
-
return entry.rules === void 0 ? { files: entry.files } : {
|
|
1315
|
-
files: entry.files,
|
|
1316
|
-
rules: entry.rules
|
|
1317
|
-
};
|
|
1318
|
-
};
|
|
1319
|
-
const compileIgnoreOverrides = (userConfig) => {
|
|
1320
|
-
const overrides = userConfig?.ignore?.overrides;
|
|
1321
|
-
if (overrides === void 0) return [];
|
|
1322
|
-
if (!Array.isArray(overrides)) {
|
|
1323
|
-
warnConfigField(`ignore.overrides must be an array of { files, rules } entries; ignoring.`);
|
|
1324
|
-
return [];
|
|
1325
|
-
}
|
|
1326
|
-
return overrides.flatMap((entry, index) => {
|
|
1327
|
-
const validated = validateOverrideEntry(entry, index);
|
|
1328
|
-
if (!validated) return [];
|
|
1329
|
-
const filePatterns = collectStringList(validated.files).map(compileGlobPattern);
|
|
1330
|
-
if (filePatterns.length === 0) return [];
|
|
1331
|
-
return [{
|
|
1332
|
-
filePatterns,
|
|
1333
|
-
ruleIds: new Set(collectStringList(validated.rules))
|
|
1334
|
-
}];
|
|
1335
|
-
});
|
|
1336
|
-
};
|
|
1337
|
-
const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) => {
|
|
1338
|
-
if (overrides.length === 0) return false;
|
|
1339
|
-
const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory);
|
|
1340
|
-
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1341
|
-
return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
|
|
1342
|
-
};
|
|
1343
|
-
//#endregion
|
|
1344
|
-
//#region src/utils/find-jsx-opener-span.ts
|
|
1345
|
-
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
1346
|
-
const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
|
|
1347
|
-
const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
|
|
1348
|
-
let stringDelimiter = null;
|
|
1349
|
-
for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
|
|
1350
|
-
const character = line[charIndex];
|
|
1351
|
-
if (stringDelimiter !== null) {
|
|
1352
|
-
if (character === "\\") {
|
|
1353
|
-
charIndex++;
|
|
1354
|
-
continue;
|
|
1355
|
-
}
|
|
1356
|
-
if (character === stringDelimiter) stringDelimiter = null;
|
|
1357
|
-
continue;
|
|
1358
|
-
}
|
|
1359
|
-
if (character === "\"" || character === "'" || character === "`") {
|
|
1360
|
-
stringDelimiter = character;
|
|
1361
|
-
continue;
|
|
1362
|
-
}
|
|
1363
|
-
if (character === "/" && line[charIndex + 1] === "/") return true;
|
|
1364
|
-
}
|
|
1365
|
-
return false;
|
|
1366
|
-
};
|
|
1367
|
-
const findOpenerTagOnLine = (line) => {
|
|
1368
|
-
for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
|
|
1369
|
-
if (match.index === void 0) continue;
|
|
1370
|
-
if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
|
|
1371
|
-
}
|
|
1372
|
-
return null;
|
|
1373
|
-
};
|
|
1374
|
-
const findJsxOpenerSpan = (lines, openerLineIndex) => {
|
|
1375
|
-
const openerLine = lines[openerLineIndex];
|
|
1376
|
-
if (openerLine === void 0) return null;
|
|
1377
|
-
const opener = findOpenerTagOnLine(openerLine);
|
|
1378
|
-
if (!opener) return null;
|
|
1379
|
-
const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
|
|
1380
|
-
let braceDepth = 0;
|
|
1381
|
-
let innerAngleDepth = 0;
|
|
1382
|
-
let stringDelimiter = null;
|
|
1383
|
-
for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
|
|
1384
|
-
const currentLine = lines[lineIndex];
|
|
1385
|
-
const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
|
|
1386
|
-
for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
|
|
1387
|
-
const character = currentLine[charIndex];
|
|
1388
|
-
if (stringDelimiter !== null) {
|
|
1389
|
-
if (character === "\\") {
|
|
1390
|
-
charIndex++;
|
|
1391
|
-
continue;
|
|
1392
|
-
}
|
|
1393
|
-
if (character === stringDelimiter) stringDelimiter = null;
|
|
1394
|
-
continue;
|
|
1395
|
-
}
|
|
1396
|
-
if (character === "\"" || character === "'" || character === "`") {
|
|
1397
|
-
stringDelimiter = character;
|
|
1398
|
-
continue;
|
|
1399
|
-
}
|
|
1400
|
-
if (character === "{") {
|
|
1401
|
-
braceDepth++;
|
|
1402
|
-
continue;
|
|
1403
|
-
}
|
|
1404
|
-
if (character === "}") {
|
|
1405
|
-
braceDepth--;
|
|
1406
|
-
continue;
|
|
1407
|
-
}
|
|
1408
|
-
if (braceDepth !== 0) continue;
|
|
1409
|
-
if (character === "<") {
|
|
1410
|
-
const followCharacter = currentLine[charIndex + 1];
|
|
1411
|
-
if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
|
|
1412
|
-
continue;
|
|
1413
|
-
}
|
|
1414
|
-
if (character !== ">") continue;
|
|
1415
|
-
const previousCharacter = currentLine[charIndex - 1];
|
|
1416
|
-
const nextCharacter = currentLine[charIndex + 1];
|
|
1417
|
-
if (previousCharacter === "=" || nextCharacter === "=") continue;
|
|
1418
|
-
if (innerAngleDepth > 0) {
|
|
1419
|
-
innerAngleDepth--;
|
|
1420
|
-
continue;
|
|
1421
|
-
}
|
|
1422
|
-
return lineIndex;
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
return null;
|
|
1426
|
-
};
|
|
1427
|
-
//#endregion
|
|
1428
|
-
//#region src/utils/find-enclosing-jsx-opener.ts
|
|
1429
|
-
const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
|
|
1430
|
-
for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
|
|
1431
|
-
const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
|
|
1432
|
-
if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
|
|
1433
|
-
}
|
|
1434
|
-
return null;
|
|
1435
|
-
};
|
|
1436
|
-
//#endregion
|
|
1437
|
-
//#region src/utils/find-stacked-disable-comments.ts
|
|
1438
|
-
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1439
|
-
const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
1440
|
-
const collected = [];
|
|
1441
|
-
let isStillInChain = true;
|
|
1442
|
-
for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
|
|
1443
|
-
const candidateLine = lines[candidateIndex];
|
|
1444
|
-
if (candidateLine === void 0) break;
|
|
1445
|
-
const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
|
|
1446
|
-
if (match) {
|
|
1447
|
-
collected.push({
|
|
1448
|
-
commentLineIndex: candidateIndex,
|
|
1449
|
-
ruleList: match[1],
|
|
1450
|
-
isInChain: isStillInChain
|
|
1451
|
-
});
|
|
1452
|
-
continue;
|
|
1453
|
-
}
|
|
1454
|
-
isStillInChain = false;
|
|
1455
|
-
}
|
|
1456
|
-
return collected;
|
|
1457
|
-
};
|
|
1458
|
-
//#endregion
|
|
1459
|
-
//#region src/utils/is-rule-listed-in-comment.ts
|
|
1460
|
-
const stripDescriptionTail = (ruleList) => {
|
|
1461
|
-
const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
|
|
1462
|
-
if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
|
|
1463
|
-
return ruleList.slice(0, descriptionMatch.index);
|
|
1464
|
-
};
|
|
1465
|
-
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
1466
|
-
const trimmed = ruleList?.trim();
|
|
1467
|
-
if (!trimmed) return true;
|
|
1468
|
-
const ruleSection = stripDescriptionTail(trimmed).trim();
|
|
1469
|
-
if (!ruleSection) return true;
|
|
1470
|
-
return ruleSection.split(/[,\s]+/).some((token) => token.trim() === ruleId);
|
|
1471
|
-
};
|
|
1472
|
-
//#endregion
|
|
1473
|
-
//#region src/utils/evaluate-suppression.ts
|
|
1474
|
-
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1475
|
-
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
1476
|
-
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1477
|
-
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
1478
|
-
const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1479
|
-
const buildAdjacentMismatchHint = (comment, ruleId) => {
|
|
1480
|
-
const ruleListText = comment.ruleList?.trim() ?? "";
|
|
1481
|
-
return `An adjacent react-doctor-disable-next-line at line ${comment.commentLineIndex + 1} lists "${ruleListText}" — ${ruleId} is not in that list. Use the comma form: react-doctor-disable-next-line ${ruleListText}, ${ruleId}`;
|
|
1482
|
-
};
|
|
1483
|
-
const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
|
|
1484
|
-
const commentLineNumber = comment.commentLineIndex + 1;
|
|
1485
|
-
const diagnosticLineNumber = diagnosticLineIndex + 1;
|
|
1486
|
-
return `A react-doctor-disable-next-line for ${ruleId} sits at line ${commentLineNumber}, but ${formatLineGap(diagnosticLineNumber - commentLineNumber - 1)} of code separate it from the diagnostic on line ${diagnosticLineNumber}. Move the comment immediately above line ${diagnosticLineNumber}, or extract the surrounding code into a helper so the suppression is adjacent.`;
|
|
1487
|
-
};
|
|
1488
|
-
const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
|
|
1489
|
-
for (const comments of commentsByAnchor) {
|
|
1490
|
-
const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
|
|
1491
|
-
if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
|
|
1492
|
-
const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
|
|
1493
|
-
if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
|
|
1494
|
-
}
|
|
1495
|
-
return null;
|
|
1496
|
-
};
|
|
1497
|
-
const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
|
|
1498
|
-
const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
|
|
1499
|
-
if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
|
|
1500
|
-
isSuppressed: true,
|
|
1501
|
-
nearMissHint: null
|
|
1502
|
-
};
|
|
1503
|
-
const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
|
|
1504
|
-
if (hasChainSuppressor(directComments, ruleId)) return {
|
|
1505
|
-
isSuppressed: true,
|
|
1506
|
-
nearMissHint: null
|
|
1507
|
-
};
|
|
1508
|
-
const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
|
|
1509
|
-
const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
|
|
1510
|
-
if (hasChainSuppressor(openerComments, ruleId)) return {
|
|
1511
|
-
isSuppressed: true,
|
|
1512
|
-
nearMissHint: null
|
|
1513
|
-
};
|
|
1514
|
-
return {
|
|
1515
|
-
isSuppressed: false,
|
|
1516
|
-
nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
|
|
1517
|
-
};
|
|
1518
|
-
};
|
|
1519
|
-
//#endregion
|
|
1520
|
-
//#region src/utils/is-ignored-file.ts
|
|
1521
|
-
const compileIgnoredFilePatterns = (userConfig) => {
|
|
1522
|
-
const files = userConfig?.ignore?.files;
|
|
1523
|
-
if (!Array.isArray(files)) return [];
|
|
1524
|
-
return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
|
|
1525
|
-
};
|
|
1526
|
-
const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
|
|
1527
|
-
if (patterns.length === 0) return false;
|
|
1528
|
-
const relativePath = toRelativePath(filePath, rootDirectory);
|
|
1529
|
-
return patterns.some((pattern) => pattern.test(relativePath));
|
|
1530
|
-
};
|
|
1531
|
-
//#endregion
|
|
1532
|
-
//#region src/utils/filter-diagnostics.ts
|
|
1533
|
-
const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
|
|
1534
|
-
const JSX_CHILD_OPEN_PATTERN = /<[A-Za-z]/;
|
|
1535
|
-
const escapeRegExpSpecials = (rawText) => rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1536
|
-
const resolveCandidateReadPath = (rootDirectory, filePath) => {
|
|
1537
|
-
const normalizedFile = filePath.replace(/\\/g, "/");
|
|
1538
|
-
if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
|
|
1539
|
-
return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
|
|
1540
|
-
};
|
|
1541
|
-
const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
|
|
1542
|
-
const cache = /* @__PURE__ */ new Map();
|
|
1543
|
-
return (filePath) => {
|
|
1544
|
-
const cached = cache.get(filePath);
|
|
1545
|
-
if (cached !== void 0) return cached;
|
|
1546
|
-
const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
|
|
1547
|
-
cache.set(filePath, lines);
|
|
1548
|
-
return lines;
|
|
1549
|
-
};
|
|
1550
|
-
};
|
|
1551
|
-
const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
|
|
1552
|
-
for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
|
|
1553
|
-
const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
|
|
1554
|
-
if (!match) continue;
|
|
1555
|
-
const fullTagName = match[1];
|
|
1556
|
-
const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
|
|
1557
|
-
return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
|
|
1558
|
-
}
|
|
1559
|
-
return false;
|
|
1560
|
-
};
|
|
1561
|
-
const findOpenerAtOrAbove = (lines, upperBoundLineIndex) => {
|
|
1562
|
-
for (let lineIndex = upperBoundLineIndex; lineIndex >= 0; lineIndex--) {
|
|
1563
|
-
const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
|
|
1564
|
-
if (!match) continue;
|
|
1565
|
-
const fullName = match[1];
|
|
1566
|
-
return {
|
|
1567
|
-
fullName,
|
|
1568
|
-
leafName: fullName.includes(".") ? fullName.split(".").at(-1) ?? fullName : fullName,
|
|
1569
|
-
lineIndex
|
|
1570
|
-
};
|
|
1571
|
-
}
|
|
1572
|
-
return null;
|
|
1573
|
-
};
|
|
1574
|
-
const resolveJsxRange = (lines, opener) => {
|
|
1575
|
-
const closingPattern = new RegExp(`</(?:${escapeRegExpSpecials(opener.fullName)}|${escapeRegExpSpecials(opener.leafName)})\\s*>`);
|
|
1576
|
-
let closerLineIndex = -1;
|
|
1577
|
-
let closerColumn = -1;
|
|
1578
|
-
for (let lineIndex = opener.lineIndex; lineIndex < lines.length; lineIndex++) {
|
|
1579
|
-
const match = closingPattern.exec(lines[lineIndex]);
|
|
1580
|
-
if (!match) continue;
|
|
1581
|
-
closerLineIndex = lineIndex;
|
|
1582
|
-
closerColumn = match.index;
|
|
1583
|
-
break;
|
|
1584
|
-
}
|
|
1585
|
-
if (closerLineIndex < 0) return null;
|
|
1586
|
-
const openerLine = lines[opener.lineIndex];
|
|
1587
|
-
const tagStartIndex = openerLine.indexOf(`<${opener.fullName}`);
|
|
1588
|
-
if (tagStartIndex < 0) return null;
|
|
1589
|
-
const openerEndIndex = openerLine.indexOf(">", tagStartIndex);
|
|
1590
|
-
let bodyText;
|
|
1591
|
-
if (opener.lineIndex === closerLineIndex) {
|
|
1592
|
-
if (openerEndIndex < 0 || openerEndIndex >= closerColumn) return null;
|
|
1593
|
-
bodyText = openerLine.slice(openerEndIndex + 1, closerColumn);
|
|
1594
|
-
} else {
|
|
1595
|
-
const segments = [];
|
|
1596
|
-
if (openerEndIndex >= 0) segments.push(openerLine.slice(openerEndIndex + 1));
|
|
1597
|
-
for (let lineIndex = opener.lineIndex + 1; lineIndex < closerLineIndex; lineIndex++) segments.push(lines[lineIndex]);
|
|
1598
|
-
segments.push(lines[closerLineIndex].slice(0, closerColumn));
|
|
1599
|
-
bodyText = segments.join("\n");
|
|
1600
|
-
}
|
|
1601
|
-
return {
|
|
1602
|
-
closerLineIndex,
|
|
1603
|
-
closerColumn,
|
|
1604
|
-
bodyText
|
|
1605
|
-
};
|
|
1606
|
-
};
|
|
1607
|
-
const isInsideStringOnlyWrapper = (lines, diagnosticLine, diagnosticColumn, wrapperNames) => {
|
|
1608
|
-
const diagnosticLineIndex = diagnosticLine - 1;
|
|
1609
|
-
const diagnosticColumnIndex = Math.max(0, diagnosticColumn - 1);
|
|
1610
|
-
let upperBoundLineIndex = diagnosticLineIndex;
|
|
1611
|
-
while (upperBoundLineIndex >= 0) {
|
|
1612
|
-
const opener = findOpenerAtOrAbove(lines, upperBoundLineIndex);
|
|
1613
|
-
if (!opener) return false;
|
|
1614
|
-
const range = resolveJsxRange(lines, opener);
|
|
1615
|
-
if (range === null) {
|
|
1616
|
-
upperBoundLineIndex = opener.lineIndex - 1;
|
|
1617
|
-
continue;
|
|
1618
|
-
}
|
|
1619
|
-
if (range.closerLineIndex < diagnosticLineIndex || range.closerLineIndex === diagnosticLineIndex && range.closerColumn <= diagnosticColumnIndex) {
|
|
1620
|
-
upperBoundLineIndex = opener.lineIndex - 1;
|
|
1621
|
-
continue;
|
|
1622
|
-
}
|
|
1623
|
-
if (!wrapperNames.has(opener.fullName) && !wrapperNames.has(opener.leafName)) return false;
|
|
1624
|
-
return !JSX_CHILD_OPEN_PATTERN.test(range.bodyText);
|
|
1625
|
-
}
|
|
1626
|
-
return false;
|
|
1627
|
-
};
|
|
1628
|
-
const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
|
|
1629
|
-
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
|
|
1630
|
-
const ignoredFilePatterns = compileIgnoredFilePatterns(config);
|
|
1631
|
-
const compiledOverrides = compileIgnoreOverrides(config);
|
|
1632
|
-
const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
|
|
1633
|
-
const hasTextComponents = textComponentNames.size > 0;
|
|
1634
|
-
const rawTextWrapperComponentNames = new Set(Array.isArray(config.rawTextWrapperComponents) ? config.rawTextWrapperComponents.filter((name) => typeof name === "string") : []);
|
|
1635
|
-
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
1636
|
-
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
1637
|
-
return diagnostics.filter((diagnostic) => {
|
|
1638
|
-
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1639
|
-
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
1640
|
-
if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
|
|
1641
|
-
if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
|
|
1642
|
-
if ((hasTextComponents || hasRawTextWrappers) && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
|
|
1643
|
-
const lines = getFileLines(diagnostic.filePath);
|
|
1644
|
-
if (lines) {
|
|
1645
|
-
if (hasTextComponents && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
|
|
1646
|
-
if (hasRawTextWrappers && isInsideStringOnlyWrapper(lines, diagnostic.line, diagnostic.column, rawTextWrapperComponentNames)) return false;
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
return true;
|
|
1650
|
-
});
|
|
1651
|
-
};
|
|
1652
|
-
const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
|
|
1653
|
-
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
1654
|
-
return diagnostics.flatMap((diagnostic) => {
|
|
1655
|
-
if (diagnostic.line <= 0) return [diagnostic];
|
|
1656
|
-
const lines = getFileLines(diagnostic.filePath);
|
|
1657
|
-
if (!lines) return [diagnostic];
|
|
1658
|
-
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1659
|
-
const evaluation = evaluateSuppression(lines, diagnostic.line - 1, ruleIdentifier);
|
|
1660
|
-
if (evaluation.isSuppressed) return [];
|
|
1661
|
-
return evaluation.nearMissHint ? [{
|
|
1662
|
-
...diagnostic,
|
|
1663
|
-
suppressionHint: evaluation.nearMissHint
|
|
1664
|
-
}] : [diagnostic];
|
|
1665
|
-
});
|
|
1666
|
-
};
|
|
1667
|
-
//#endregion
|
|
1668
|
-
//#region src/utils/merge-and-filter-diagnostics.ts
|
|
1669
|
-
const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync, options = {}) => {
|
|
1670
|
-
const filtered = userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics;
|
|
1671
|
-
if (options.respectInlineDisables === false) return filtered;
|
|
1672
|
-
return filterInlineSuppressions(filtered, directory, readFileLinesSync);
|
|
1673
|
-
};
|
|
1674
|
-
//#endregion
|
|
1675
|
-
//#region src/utils/parse-react-major.ts
|
|
1676
|
-
const parseReactMajor = (reactVersion) => {
|
|
1677
|
-
if (typeof reactVersion !== "string") return null;
|
|
1678
|
-
const trimmed = reactVersion.trim();
|
|
1679
|
-
if (trimmed.length === 0) return null;
|
|
1680
|
-
const match = trimmed.match(/(\d+)/);
|
|
1681
|
-
if (!match) return null;
|
|
1682
|
-
const major = Number.parseInt(match[1], 10);
|
|
1683
|
-
if (!Number.isFinite(major) || major <= 0) return null;
|
|
1684
|
-
return major;
|
|
1685
|
-
};
|
|
1686
|
-
//#endregion
|
|
1687
|
-
//#region src/utils/read-file-lines-node.ts
|
|
1688
|
-
const createNodeReadFileLinesSync = (rootDirectory) => {
|
|
1689
|
-
return (filePath) => {
|
|
1690
|
-
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
|
|
1691
|
-
try {
|
|
1692
|
-
return fs.readFileSync(absolutePath, "utf-8").split("\n");
|
|
1693
|
-
} catch {
|
|
1694
|
-
return null;
|
|
1695
|
-
}
|
|
1696
|
-
};
|
|
1697
|
-
};
|
|
1698
|
-
//#endregion
|
|
1699
|
-
//#region src/utils/resolve-config-root-dir.ts
|
|
1700
|
-
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
1701
|
-
if (!config || !configSourceDirectory) return null;
|
|
1702
|
-
const rawRootDir = config.rootDir;
|
|
1703
|
-
if (typeof rawRootDir !== "string") return null;
|
|
1704
|
-
const trimmedRootDir = rawRootDir.trim();
|
|
1705
|
-
if (trimmedRootDir.length === 0) return null;
|
|
1706
|
-
const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
|
|
1707
|
-
if (resolvedRootDir === configSourceDirectory) return null;
|
|
1708
|
-
if (!fs.existsSync(resolvedRootDir) || !fs.statSync(resolvedRootDir).isDirectory()) {
|
|
1709
|
-
logger.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`);
|
|
1710
|
-
return null;
|
|
1711
|
-
}
|
|
1712
|
-
return resolvedRootDir;
|
|
1713
|
-
};
|
|
1714
|
-
//#endregion
|
|
1715
|
-
//#region src/utils/resolve-diagnose-target.ts
|
|
1716
|
-
const resolveDiagnoseTarget = (directory) => {
|
|
1717
|
-
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
1718
|
-
const reactSubprojects = discoverReactSubprojects(directory);
|
|
1719
|
-
if (reactSubprojects.length === 0) return null;
|
|
1720
|
-
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
1721
|
-
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
1722
|
-
};
|
|
1723
|
-
//#endregion
|
|
1724
|
-
//#region src/utils/resolve-lint-include-paths.ts
|
|
1725
|
-
const listSourceFilesViaGit = (rootDirectory) => {
|
|
1726
|
-
const result = spawnSync("git", [
|
|
1727
|
-
"ls-files",
|
|
1728
|
-
"-z",
|
|
1729
|
-
"--cached",
|
|
1730
|
-
"--others",
|
|
1731
|
-
"--exclude-standard",
|
|
1732
|
-
"--recurse-submodules"
|
|
1733
|
-
], {
|
|
1734
|
-
cwd: rootDirectory,
|
|
1735
|
-
encoding: "utf-8",
|
|
1736
|
-
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
1737
|
-
});
|
|
1738
|
-
if (result.error || result.status !== 0) return null;
|
|
1739
|
-
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
|
|
1740
|
-
};
|
|
1741
|
-
const listSourceFilesViaFilesystem = (rootDirectory) => {
|
|
1742
|
-
const filePaths = [];
|
|
1743
|
-
const stack = [rootDirectory];
|
|
1744
|
-
while (stack.length > 0) {
|
|
1745
|
-
const currentDirectory = stack.pop();
|
|
1746
|
-
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
|
|
1747
|
-
for (const entry of entries) {
|
|
1748
|
-
const absolutePath = path.join(currentDirectory, entry.name);
|
|
1749
|
-
if (entry.isDirectory()) {
|
|
1750
|
-
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
|
|
1751
|
-
continue;
|
|
1752
|
-
}
|
|
1753
|
-
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
return filePaths;
|
|
1757
|
-
};
|
|
1758
|
-
const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
|
|
1759
|
-
const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
1760
|
-
if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
|
|
1761
|
-
const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
|
|
1762
|
-
return listSourceFiles(rootDirectory).filter((filePath) => {
|
|
1763
|
-
if (!JSX_FILE_PATTERN.test(filePath)) return false;
|
|
1764
|
-
return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
|
|
1765
|
-
});
|
|
1766
|
-
};
|
|
1767
|
-
//#endregion
|
|
1768
|
-
//#region src/utils/collect-unused-file-paths.ts
|
|
1769
|
-
const collectUnusedFilePaths = (filesIssues) => {
|
|
1770
|
-
if (filesIssues instanceof Set) return [...filesIssues];
|
|
1771
|
-
if (Array.isArray(filesIssues)) return filesIssues.filter((entry) => typeof entry === "string");
|
|
1772
|
-
if (!isPlainObject(filesIssues)) return [];
|
|
1773
|
-
const unusedFilePaths = [];
|
|
1774
|
-
for (const innerValue of Object.values(filesIssues)) {
|
|
1775
|
-
if (!isPlainObject(innerValue)) continue;
|
|
1776
|
-
for (const issue of Object.values(innerValue)) if (isPlainObject(issue) && typeof issue.filePath === "string") unusedFilePaths.push(issue.filePath);
|
|
1777
|
-
}
|
|
1778
|
-
return unusedFilePaths;
|
|
1779
|
-
};
|
|
1780
|
-
//#endregion
|
|
1781
|
-
//#region src/utils/extract-failed-plugin-name.ts
|
|
1782
|
-
const PLUGIN_CONFIG_PATTERN = /(?:^|[/\\\s])([a-z][a-z0-9-]*)\.config\./i;
|
|
1783
|
-
const RC_DOTFILE_PATTERN = /(?:^|[/\\])\.([a-z][a-z0-9-]*?)rc(?:\.[a-z]+)?(?:\b|$)/i;
|
|
1784
|
-
const extractFailedPluginName = (error) => {
|
|
1785
|
-
for (const errorMessage of getErrorChainMessages(error)) {
|
|
1786
|
-
const pluginNameMatch = errorMessage.match(PLUGIN_CONFIG_PATTERN);
|
|
1787
|
-
if (pluginNameMatch?.[1]) return pluginNameMatch[1].toLowerCase();
|
|
1788
|
-
const rcMatch = errorMessage.match(RC_DOTFILE_PATTERN);
|
|
1789
|
-
if (rcMatch?.[1]) return rcMatch[1].toLowerCase();
|
|
1790
|
-
}
|
|
1791
|
-
return null;
|
|
1792
|
-
};
|
|
1793
|
-
//#endregion
|
|
1794
|
-
//#region src/utils/has-knip-config.ts
|
|
1795
|
-
const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
|
|
1796
|
-
//#endregion
|
|
1797
|
-
//#region src/utils/sanitize-knip-config-patterns.ts
|
|
1798
|
-
const isMeaningfulPattern = (value) => typeof value !== "string" || value.trim().length > 0;
|
|
1799
|
-
const sanitizeStringArray = (values) => values.filter((entry) => typeof entry === "string" ? entry.trim().length > 0 : true);
|
|
1800
|
-
const sanitizeKnipConfigPatterns = (parsedConfig) => {
|
|
1801
|
-
for (const [key, value] of Object.entries(parsedConfig)) {
|
|
1802
|
-
if (typeof value === "string") {
|
|
1803
|
-
if (!isMeaningfulPattern(value)) delete parsedConfig[key];
|
|
1804
|
-
continue;
|
|
1805
|
-
}
|
|
1806
|
-
if (Array.isArray(value)) {
|
|
1807
|
-
if (value.length === 0) continue;
|
|
1808
|
-
const sanitized = sanitizeStringArray(value);
|
|
1809
|
-
if (sanitized.length === value.length) continue;
|
|
1810
|
-
if (sanitized.length === 0) delete parsedConfig[key];
|
|
1811
|
-
else parsedConfig[key] = sanitized;
|
|
1812
|
-
continue;
|
|
1813
|
-
}
|
|
1814
|
-
if (isPlainObject(value)) sanitizeKnipConfigPatterns(value);
|
|
1815
|
-
}
|
|
1816
|
-
};
|
|
1817
|
-
//#endregion
|
|
1818
|
-
//#region src/utils/run-knip.ts
|
|
1819
|
-
const KNIP_ISSUE_TYPE_DESCRIPTORS = new Map([
|
|
1820
|
-
["files", {
|
|
1821
|
-
category: "Dead Code",
|
|
1822
|
-
message: "Unused file",
|
|
1823
|
-
severity: "warning"
|
|
1824
|
-
}],
|
|
1825
|
-
["exports", {
|
|
1826
|
-
category: "Dead Code",
|
|
1827
|
-
message: "Unused export",
|
|
1828
|
-
severity: "warning"
|
|
1829
|
-
}],
|
|
1830
|
-
["types", {
|
|
1831
|
-
category: "Dead Code",
|
|
1832
|
-
message: "Unused type",
|
|
1833
|
-
severity: "warning"
|
|
1834
|
-
}],
|
|
1835
|
-
["duplicates", {
|
|
1836
|
-
category: "Dead Code",
|
|
1837
|
-
message: "Duplicate export",
|
|
1838
|
-
severity: "warning"
|
|
1839
|
-
}]
|
|
1840
|
-
]);
|
|
1841
|
-
const FALLBACK_KNIP_DESCRIPTOR = {
|
|
1842
|
-
category: "Dead Code",
|
|
1843
|
-
message: "Issue",
|
|
1844
|
-
severity: "warning"
|
|
1845
|
-
};
|
|
1846
|
-
const collectIssueRecords = (records, issueType, rootDirectory) => {
|
|
1847
|
-
const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get(issueType) ?? FALLBACK_KNIP_DESCRIPTOR;
|
|
1848
|
-
const diagnostics = [];
|
|
1849
|
-
for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
|
|
1850
|
-
filePath: path.relative(rootDirectory, issue.filePath),
|
|
1851
|
-
plugin: "knip",
|
|
1852
|
-
rule: issueType,
|
|
1853
|
-
severity: descriptor.severity,
|
|
1854
|
-
message: `${descriptor.message}: ${issue.symbol}`,
|
|
1855
|
-
help: "",
|
|
1856
|
-
line: 0,
|
|
1857
|
-
column: 0,
|
|
1858
|
-
category: descriptor.category
|
|
1859
|
-
});
|
|
1860
|
-
return diagnostics;
|
|
1861
|
-
};
|
|
1862
|
-
const silenced = async (fn) => {
|
|
1863
|
-
const originalLog = console.log;
|
|
1864
|
-
const originalInfo = console.info;
|
|
1865
|
-
const originalWarn = console.warn;
|
|
1866
|
-
const originalError = console.error;
|
|
1867
|
-
const noop = () => {};
|
|
1868
|
-
console.log = noop;
|
|
1869
|
-
console.info = noop;
|
|
1870
|
-
console.warn = noop;
|
|
1871
|
-
console.error = noop;
|
|
1872
|
-
try {
|
|
1873
|
-
return await fn();
|
|
1874
|
-
} finally {
|
|
1875
|
-
console.log = originalLog;
|
|
1876
|
-
console.info = originalInfo;
|
|
1877
|
-
console.warn = originalWarn;
|
|
1878
|
-
console.error = originalError;
|
|
1879
|
-
}
|
|
1880
|
-
};
|
|
1881
|
-
const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
|
|
1882
|
-
const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
|
|
1883
|
-
const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
|
|
1884
|
-
const failedPlugin = extractFailedPluginName(error);
|
|
1885
|
-
if (!failedPlugin || !Object.hasOwn(parsedConfig, failedPlugin) || disabledPlugins.has(failedPlugin)) return false;
|
|
1886
|
-
disabledPlugins.add(failedPlugin);
|
|
1887
|
-
parsedConfig[failedPlugin] = false;
|
|
1888
|
-
return true;
|
|
1889
|
-
};
|
|
1890
|
-
const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
1891
|
-
const tsConfigFile = resolveTsConfigFile(knipCwd);
|
|
1892
|
-
const options = await silenced(() => createOptions({
|
|
1893
|
-
cwd: knipCwd,
|
|
1894
|
-
isShowProgress: false,
|
|
1895
|
-
...workspaceName ? { workspace: workspaceName } : {},
|
|
1896
|
-
...tsConfigFile ? { tsConfigFile } : {}
|
|
1897
|
-
}));
|
|
1898
|
-
const parsedConfig = options.parsedConfig;
|
|
1899
|
-
sanitizeKnipConfigPatterns(parsedConfig);
|
|
1900
|
-
const disabledPlugins = /* @__PURE__ */ new Set();
|
|
1901
|
-
let lastKnipError;
|
|
1902
|
-
for (let attempt = 0; attempt < 6; attempt++) try {
|
|
1903
|
-
return await silenced(() => main(options));
|
|
1904
|
-
} catch (error) {
|
|
1905
|
-
lastKnipError = error;
|
|
1906
|
-
if (!tryDisableFailedPlugin(error, parsedConfig, disabledPlugins)) throw error;
|
|
1907
|
-
}
|
|
1908
|
-
throw lastKnipError;
|
|
1909
|
-
};
|
|
1910
|
-
const hasNodeModules = (directory) => {
|
|
1911
|
-
const nodeModulesPath = path.join(directory, "node_modules");
|
|
1912
|
-
return fs.existsSync(nodeModulesPath) && fs.statSync(nodeModulesPath).isDirectory();
|
|
1913
|
-
};
|
|
1914
|
-
const resolveWorkspaceName = (rootDirectory) => {
|
|
1915
|
-
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
1916
|
-
return (isFile(packageJsonPath) ? readPackageJson(packageJsonPath) : {}).name ?? path.basename(rootDirectory);
|
|
1917
|
-
};
|
|
1918
|
-
const runKnipForProject = async (rootDirectory, monorepoRoot) => {
|
|
1919
|
-
if (!monorepoRoot || hasKnipConfig(rootDirectory)) return runKnipWithOptions(rootDirectory);
|
|
1920
|
-
try {
|
|
1921
|
-
return await runKnipWithOptions(monorepoRoot, resolveWorkspaceName(rootDirectory));
|
|
1922
|
-
} catch {
|
|
1923
|
-
return runKnipWithOptions(rootDirectory);
|
|
1924
|
-
}
|
|
1925
|
-
};
|
|
1926
|
-
const runKnip = async (rootDirectory) => {
|
|
1927
|
-
const monorepoRoot = findMonorepoRoot(rootDirectory);
|
|
1928
|
-
if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
|
|
1929
|
-
const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
|
|
1930
|
-
const diagnostics = [];
|
|
1931
|
-
const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get("files") ?? FALLBACK_KNIP_DESCRIPTOR;
|
|
1932
|
-
for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
|
|
1933
|
-
filePath: path.relative(rootDirectory, unusedFilePath),
|
|
1934
|
-
plugin: "knip",
|
|
1935
|
-
rule: "files",
|
|
1936
|
-
severity: filesDescriptor.severity,
|
|
1937
|
-
message: filesDescriptor.message,
|
|
1938
|
-
help: "This file is not imported by any other file in the project.",
|
|
1939
|
-
line: 0,
|
|
1940
|
-
column: 0,
|
|
1941
|
-
category: filesDescriptor.category
|
|
1942
|
-
});
|
|
1943
|
-
for (const issueType of [
|
|
1944
|
-
"exports",
|
|
1945
|
-
"types",
|
|
1946
|
-
"duplicates"
|
|
1947
|
-
]) diagnostics.push(...collectIssueRecords(issues[issueType], issueType, rootDirectory));
|
|
1948
|
-
return diagnostics;
|
|
1949
|
-
};
|
|
1950
|
-
//#endregion
|
|
1951
|
-
//#region src/utils/batch-include-paths.ts
|
|
1952
|
-
const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
|
|
1953
|
-
const batchIncludePaths = (baseArgs, includePaths) => {
|
|
1954
|
-
const baseArgsLength = estimateArgsLength(baseArgs);
|
|
1955
|
-
const batches = [];
|
|
1956
|
-
let currentBatch = [];
|
|
1957
|
-
let currentBatchLength = baseArgsLength;
|
|
1958
|
-
for (const filePath of includePaths) {
|
|
1959
|
-
const entryLength = filePath.length + 1;
|
|
1960
|
-
const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > 24e3;
|
|
1961
|
-
const exceedsFileCount = currentBatch.length >= 500;
|
|
1962
|
-
if (exceedsArgLength || exceedsFileCount) {
|
|
1963
|
-
batches.push(currentBatch);
|
|
1964
|
-
currentBatch = [];
|
|
1965
|
-
currentBatchLength = baseArgsLength;
|
|
1966
|
-
}
|
|
1967
|
-
currentBatch.push(filePath);
|
|
1968
|
-
currentBatchLength += entryLength;
|
|
1969
|
-
}
|
|
1970
|
-
if (currentBatch.length > 0) batches.push(currentBatch);
|
|
1971
|
-
return batches;
|
|
1972
|
-
};
|
|
1973
|
-
//#endregion
|
|
1974
|
-
//#region src/utils/can-oxlint-extend-config.ts
|
|
1975
|
-
const EXTENDS_LOCAL_PATH_PREFIXES = [
|
|
1976
|
-
"./",
|
|
1977
|
-
"../",
|
|
1978
|
-
"/"
|
|
1979
|
-
];
|
|
1980
|
-
const isLocalPathExtend = (entry) => {
|
|
1981
|
-
for (const prefix of EXTENDS_LOCAL_PATH_PREFIXES) if (entry.startsWith(prefix)) return true;
|
|
1982
|
-
return false;
|
|
1983
|
-
};
|
|
1984
|
-
const stripJsoncComments = (raw) => {
|
|
1985
|
-
let result = "";
|
|
1986
|
-
let cursor = 0;
|
|
1987
|
-
let inString = false;
|
|
1988
|
-
let stringQuote = "";
|
|
1989
|
-
while (cursor < raw.length) {
|
|
1990
|
-
const character = raw[cursor];
|
|
1991
|
-
const nextCharacter = raw[cursor + 1];
|
|
1992
|
-
if (inString) {
|
|
1993
|
-
result += character;
|
|
1994
|
-
if (character === "\\" && cursor + 1 < raw.length) {
|
|
1995
|
-
result += nextCharacter;
|
|
1996
|
-
cursor += 2;
|
|
1997
|
-
continue;
|
|
1998
|
-
}
|
|
1999
|
-
if (character === stringQuote) inString = false;
|
|
2000
|
-
cursor += 1;
|
|
2001
|
-
continue;
|
|
2002
|
-
}
|
|
2003
|
-
if (character === "\"" || character === "'") {
|
|
2004
|
-
inString = true;
|
|
2005
|
-
stringQuote = character;
|
|
2006
|
-
result += character;
|
|
2007
|
-
cursor += 1;
|
|
2008
|
-
continue;
|
|
2009
|
-
}
|
|
2010
|
-
if (character === "/" && nextCharacter === "/") {
|
|
2011
|
-
const lineEndIndex = raw.indexOf("\n", cursor);
|
|
2012
|
-
cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
|
|
2013
|
-
continue;
|
|
2014
|
-
}
|
|
2015
|
-
if (character === "/" && nextCharacter === "*") {
|
|
2016
|
-
const blockEndIndex = raw.indexOf("*/", cursor + 2);
|
|
2017
|
-
cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
|
|
2018
|
-
continue;
|
|
2019
|
-
}
|
|
2020
|
-
result += character;
|
|
2021
|
-
cursor += 1;
|
|
2022
|
-
}
|
|
2023
|
-
return result;
|
|
2024
|
-
};
|
|
2025
|
-
const parseJsonOrJsonc = (raw) => {
|
|
2026
|
-
try {
|
|
2027
|
-
return JSON.parse(raw);
|
|
2028
|
-
} catch {
|
|
2029
|
-
return JSON.parse(stripJsoncComments(raw));
|
|
2030
|
-
}
|
|
2031
|
-
};
|
|
2032
|
-
const canOxlintExtendConfig = (configPath) => {
|
|
2033
|
-
if (!configPath.endsWith(".eslintrc.json")) return true;
|
|
2034
|
-
let parsed;
|
|
2035
|
-
try {
|
|
2036
|
-
parsed = parseJsonOrJsonc(fs.readFileSync(configPath, "utf-8"));
|
|
2037
|
-
} catch {
|
|
2038
|
-
return true;
|
|
2039
|
-
}
|
|
2040
|
-
if (!isPlainObject(parsed)) return true;
|
|
2041
|
-
const extendsValue = parsed.extends;
|
|
2042
|
-
if (extendsValue === void 0 || extendsValue === null) return true;
|
|
2043
|
-
const extendsEntries = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
|
|
2044
|
-
if (extendsEntries.length === 0) return true;
|
|
2045
|
-
return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
|
|
2046
|
-
};
|
|
2047
|
-
//#endregion
|
|
2048
|
-
//#region src/utils/detect-user-lint-config.ts
|
|
2049
|
-
const findFirstLintConfigInDirectory = (directory) => {
|
|
2050
|
-
for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
|
|
2051
|
-
const candidatePath = path.join(directory, filename);
|
|
2052
|
-
if (isFile(candidatePath)) return candidatePath;
|
|
2053
|
-
}
|
|
2054
|
-
return null;
|
|
2055
|
-
};
|
|
2056
|
-
const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
2057
|
-
const detectUserLintConfigPaths = (rootDirectory) => {
|
|
2058
|
-
const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
|
|
2059
|
-
if (directLintConfig) return [directLintConfig];
|
|
2060
|
-
if (isProjectBoundary(rootDirectory)) return [];
|
|
2061
|
-
let ancestorDirectory = path.dirname(rootDirectory);
|
|
2062
|
-
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
2063
|
-
const ancestorLintConfig = findFirstLintConfigInDirectory(ancestorDirectory);
|
|
2064
|
-
if (ancestorLintConfig) return [ancestorLintConfig];
|
|
2065
|
-
if (isProjectBoundary(ancestorDirectory)) return [];
|
|
2066
|
-
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
2067
|
-
}
|
|
2068
|
-
return [];
|
|
2069
|
-
};
|
|
2070
|
-
//#endregion
|
|
2071
|
-
//#region src/oxlint-config.ts
|
|
2072
|
-
const esmRequire$1 = createRequire(import.meta.url);
|
|
2073
|
-
const NEXTJS_RULES = {
|
|
2074
|
-
"react-doctor/nextjs-no-img-element": "warn",
|
|
2075
|
-
"react-doctor/nextjs-async-client-component": "error",
|
|
2076
|
-
"react-doctor/nextjs-no-a-element": "warn",
|
|
2077
|
-
"react-doctor/nextjs-no-use-search-params-without-suspense": "warn",
|
|
2078
|
-
"react-doctor/nextjs-no-client-fetch-for-server-data": "warn",
|
|
2079
|
-
"react-doctor/nextjs-missing-metadata": "warn",
|
|
2080
|
-
"react-doctor/nextjs-no-client-side-redirect": "warn",
|
|
2081
|
-
"react-doctor/nextjs-no-redirect-in-try-catch": "warn",
|
|
2082
|
-
"react-doctor/nextjs-image-missing-sizes": "warn",
|
|
2083
|
-
"react-doctor/nextjs-no-native-script": "warn",
|
|
2084
|
-
"react-doctor/nextjs-inline-script-missing-id": "warn",
|
|
2085
|
-
"react-doctor/nextjs-no-font-link": "warn",
|
|
2086
|
-
"react-doctor/nextjs-no-css-link": "warn",
|
|
2087
|
-
"react-doctor/nextjs-no-polyfill-script": "warn",
|
|
2088
|
-
"react-doctor/nextjs-no-head-import": "error",
|
|
2089
|
-
"react-doctor/nextjs-no-side-effect-in-get-handler": "error"
|
|
2090
|
-
};
|
|
2091
|
-
const REACT_NATIVE_RULES = {
|
|
2092
|
-
"react-doctor/rn-no-raw-text": "error",
|
|
2093
|
-
"react-doctor/rn-no-deprecated-modules": "error",
|
|
2094
|
-
"react-doctor/rn-no-legacy-expo-packages": "warn",
|
|
2095
|
-
"react-doctor/rn-no-dimensions-get": "warn",
|
|
2096
|
-
"react-doctor/rn-no-inline-flatlist-renderitem": "warn",
|
|
2097
|
-
"react-doctor/rn-no-legacy-shadow-styles": "warn",
|
|
2098
|
-
"react-doctor/rn-prefer-reanimated": "warn",
|
|
2099
|
-
"react-doctor/rn-no-single-element-style-array": "warn",
|
|
2100
|
-
"react-doctor/rn-prefer-pressable": "warn",
|
|
2101
|
-
"react-doctor/rn-prefer-expo-image": "warn",
|
|
2102
|
-
"react-doctor/rn-no-non-native-navigator": "warn",
|
|
2103
|
-
"react-doctor/rn-no-scroll-state": "error",
|
|
2104
|
-
"react-doctor/rn-no-scrollview-mapped-list": "warn",
|
|
2105
|
-
"react-doctor/rn-no-inline-object-in-list-item": "warn",
|
|
2106
|
-
"react-doctor/rn-animate-layout-property": "error",
|
|
2107
|
-
"react-doctor/rn-prefer-content-inset-adjustment": "warn",
|
|
2108
|
-
"react-doctor/rn-pressable-shared-value-mutation": "warn",
|
|
2109
|
-
"react-doctor/rn-list-data-mapped": "warn",
|
|
2110
|
-
"react-doctor/rn-list-callback-per-row": "warn",
|
|
2111
|
-
"react-doctor/rn-list-recyclable-without-types": "warn",
|
|
2112
|
-
"react-doctor/rn-animation-reaction-as-derived": "warn",
|
|
2113
|
-
"react-doctor/rn-bottom-sheet-prefer-native": "warn",
|
|
2114
|
-
"react-doctor/rn-scrollview-dynamic-padding": "warn",
|
|
2115
|
-
"react-doctor/rn-style-prefer-boxshadow": "warn"
|
|
2116
|
-
};
|
|
2117
|
-
const TANSTACK_START_RULES = {
|
|
2118
|
-
"react-doctor/tanstack-start-route-property-order": "error",
|
|
2119
|
-
"react-doctor/tanstack-start-no-direct-fetch-in-loader": "warn",
|
|
2120
|
-
"react-doctor/tanstack-start-server-fn-validate-input": "warn",
|
|
2121
|
-
"react-doctor/tanstack-start-no-useeffect-fetch": "warn",
|
|
2122
|
-
"react-doctor/tanstack-start-missing-head-content": "warn",
|
|
2123
|
-
"react-doctor/tanstack-start-no-anchor-element": "warn",
|
|
2124
|
-
"react-doctor/tanstack-start-server-fn-method-order": "error",
|
|
2125
|
-
"react-doctor/tanstack-start-no-navigate-in-render": "warn",
|
|
2126
|
-
"react-doctor/tanstack-start-no-dynamic-server-fn-import": "error",
|
|
2127
|
-
"react-doctor/tanstack-start-no-use-server-in-handler": "error",
|
|
2128
|
-
"react-doctor/tanstack-start-no-secrets-in-loader": "error",
|
|
2129
|
-
"react-doctor/tanstack-start-get-mutation": "warn",
|
|
2130
|
-
"react-doctor/tanstack-start-redirect-in-try-catch": "warn",
|
|
2131
|
-
"react-doctor/tanstack-start-loader-parallel-fetch": "warn"
|
|
2132
|
-
};
|
|
2133
|
-
const YOU_MIGHT_NOT_NEED_EFFECT_RULES = {
|
|
2134
|
-
"effect/no-derived-state": "warn",
|
|
2135
|
-
"effect/no-chain-state-updates": "warn",
|
|
2136
|
-
"effect/no-event-handler": "warn",
|
|
2137
|
-
"effect/no-adjust-state-on-prop-change": "warn",
|
|
2138
|
-
"effect/no-reset-all-state-on-prop-change": "warn",
|
|
2139
|
-
"effect/no-pass-live-state-to-parent": "warn",
|
|
2140
|
-
"effect/no-pass-data-to-parent": "warn",
|
|
2141
|
-
"effect/no-initialize-state": "warn"
|
|
2142
|
-
};
|
|
2143
|
-
const REACT_COMPILER_RULES = {
|
|
2144
|
-
"react-hooks-js/set-state-in-render": "error",
|
|
2145
|
-
"react-hooks-js/immutability": "error",
|
|
2146
|
-
"react-hooks-js/refs": "error",
|
|
2147
|
-
"react-hooks-js/purity": "error",
|
|
2148
|
-
"react-hooks-js/hooks": "error",
|
|
2149
|
-
"react-hooks-js/set-state-in-effect": "error",
|
|
2150
|
-
"react-hooks-js/globals": "error",
|
|
2151
|
-
"react-hooks-js/error-boundaries": "error",
|
|
2152
|
-
"react-hooks-js/preserve-manual-memoization": "error",
|
|
2153
|
-
"react-hooks-js/unsupported-syntax": "error",
|
|
2154
|
-
"react-hooks-js/component-hook-factories": "error",
|
|
2155
|
-
"react-hooks-js/static-components": "error",
|
|
2156
|
-
"react-hooks-js/use-memo": "error",
|
|
2157
|
-
"react-hooks-js/void-use-memo": "error",
|
|
2158
|
-
"react-hooks-js/incompatible-library": "error",
|
|
2159
|
-
"react-hooks-js/todo": "error"
|
|
2160
|
-
};
|
|
2161
|
-
const readPluginRuleNames = (pluginSpecifier) => {
|
|
2162
|
-
try {
|
|
2163
|
-
const pluginModule = esmRequire$1(pluginSpecifier);
|
|
2164
|
-
const rules = pluginModule.rules ?? pluginModule.default?.rules;
|
|
2165
|
-
if (rules === void 0) return /* @__PURE__ */ new Set();
|
|
2166
|
-
return new Set(Object.keys(rules));
|
|
2167
|
-
} catch {
|
|
2168
|
-
return /* @__PURE__ */ new Set();
|
|
2169
|
-
}
|
|
2170
|
-
};
|
|
2171
|
-
const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
|
|
2172
|
-
if (!hasReactCompiler || customRulesOnly) return null;
|
|
2173
|
-
let pluginSpecifier;
|
|
2174
|
-
try {
|
|
2175
|
-
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-hooks");
|
|
2176
|
-
} catch {
|
|
2177
|
-
return null;
|
|
2178
|
-
}
|
|
2179
|
-
return {
|
|
2180
|
-
entry: {
|
|
2181
|
-
name: "react-hooks-js",
|
|
2182
|
-
specifier: pluginSpecifier
|
|
2183
|
-
},
|
|
2184
|
-
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2185
|
-
};
|
|
2186
|
-
};
|
|
2187
|
-
const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";
|
|
2188
|
-
const resolveYouMightNotNeedEffectPlugin = (customRulesOnly) => {
|
|
2189
|
-
if (customRulesOnly) return null;
|
|
2190
|
-
let pluginSpecifier;
|
|
2191
|
-
try {
|
|
2192
|
-
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-you-might-not-need-an-effect");
|
|
2193
|
-
} catch {
|
|
2194
|
-
return null;
|
|
2195
|
-
}
|
|
2196
|
-
return {
|
|
2197
|
-
entry: {
|
|
2198
|
-
name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
|
|
2199
|
-
specifier: pluginSpecifier
|
|
2200
|
-
},
|
|
2201
|
-
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2202
|
-
};
|
|
2203
|
-
};
|
|
2204
|
-
const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
|
|
2205
|
-
if (availableRuleNames.size === 0) return rules;
|
|
2206
|
-
const ruleKeyPrefix = `${pluginNamespace}/`;
|
|
2207
|
-
const filtered = {};
|
|
2208
|
-
for (const [ruleKey, severity] of Object.entries(rules)) {
|
|
2209
|
-
if (!ruleKey.startsWith(ruleKeyPrefix)) {
|
|
2210
|
-
filtered[ruleKey] = severity;
|
|
2211
|
-
continue;
|
|
2212
|
-
}
|
|
2213
|
-
const ruleName = ruleKey.slice(ruleKeyPrefix.length);
|
|
2214
|
-
if (availableRuleNames.has(ruleName)) filtered[ruleKey] = severity;
|
|
2215
|
-
}
|
|
2216
|
-
return filtered;
|
|
2217
|
-
};
|
|
2218
|
-
const TANSTACK_QUERY_RULES = {
|
|
2219
|
-
"react-doctor/query-stable-query-client": "warn",
|
|
2220
|
-
"react-doctor/query-no-rest-destructuring": "warn",
|
|
2221
|
-
"react-doctor/query-no-void-query-fn": "warn",
|
|
2222
|
-
"react-doctor/query-no-query-in-effect": "warn",
|
|
2223
|
-
"react-doctor/query-mutation-missing-invalidation": "warn",
|
|
2224
|
-
"react-doctor/query-no-usequery-for-mutation": "warn"
|
|
2225
|
-
};
|
|
2226
|
-
const BUILTIN_REACT_RULES = {
|
|
2227
|
-
"react/rules-of-hooks": "error",
|
|
2228
|
-
"react/no-direct-mutation-state": "error",
|
|
2229
|
-
"react/jsx-no-duplicate-props": "error",
|
|
2230
|
-
"react/jsx-key": "error",
|
|
2231
|
-
"react/no-children-prop": "warn",
|
|
2232
|
-
"react/no-danger": "warn",
|
|
2233
|
-
"react/jsx-no-script-url": "error",
|
|
2234
|
-
"react/no-render-return-value": "warn",
|
|
2235
|
-
"react/no-string-refs": "warn",
|
|
2236
|
-
"react/no-is-mounted": "warn",
|
|
2237
|
-
"react/require-render-return": "error",
|
|
2238
|
-
"react/no-unknown-property": "warn"
|
|
2239
|
-
};
|
|
2240
|
-
const BUILTIN_A11Y_RULES = {
|
|
2241
|
-
"jsx-a11y/alt-text": "error",
|
|
2242
|
-
"jsx-a11y/anchor-is-valid": "warn",
|
|
2243
|
-
"jsx-a11y/click-events-have-key-events": "warn",
|
|
2244
|
-
"jsx-a11y/no-static-element-interactions": "warn",
|
|
2245
|
-
"jsx-a11y/role-has-required-aria-props": "error",
|
|
2246
|
-
"jsx-a11y/no-autofocus": "warn",
|
|
2247
|
-
"jsx-a11y/heading-has-content": "warn",
|
|
2248
|
-
"jsx-a11y/html-has-lang": "warn",
|
|
2249
|
-
"jsx-a11y/no-redundant-roles": "warn",
|
|
2250
|
-
"jsx-a11y/scope": "warn",
|
|
2251
|
-
"jsx-a11y/tabindex-no-positive": "warn",
|
|
2252
|
-
"jsx-a11y/label-has-associated-control": "warn",
|
|
2253
|
-
"jsx-a11y/no-distracting-elements": "error",
|
|
2254
|
-
"jsx-a11y/iframe-has-title": "warn"
|
|
2255
|
-
};
|
|
2256
|
-
const GLOBAL_REACT_DOCTOR_RULES = {
|
|
2257
|
-
"react-doctor/no-derived-state-effect": "warn",
|
|
2258
|
-
"react-doctor/no-fetch-in-effect": "warn",
|
|
2259
|
-
"react-doctor/no-mirror-prop-effect": "warn",
|
|
2260
|
-
"react-doctor/no-mutable-in-deps": "error",
|
|
2261
|
-
"react-doctor/no-cascading-set-state": "warn",
|
|
2262
|
-
"react-doctor/no-effect-chain": "warn",
|
|
2263
|
-
"react-doctor/no-effect-event-handler": "warn",
|
|
2264
|
-
"react-doctor/no-effect-event-in-deps": "error",
|
|
2265
|
-
"react-doctor/no-event-trigger-state": "warn",
|
|
2266
|
-
"react-doctor/no-prop-callback-in-effect": "warn",
|
|
2267
|
-
"react-doctor/no-derived-useState": "warn",
|
|
2268
|
-
"react-doctor/no-direct-state-mutation": "warn",
|
|
2269
|
-
"react-doctor/no-set-state-in-render": "warn",
|
|
2270
|
-
"react-doctor/prefer-use-effect-event": "warn",
|
|
2271
|
-
"react-doctor/prefer-useReducer": "warn",
|
|
2272
|
-
"react-doctor/prefer-use-sync-external-store": "warn",
|
|
2273
|
-
"react-doctor/rerender-lazy-state-init": "warn",
|
|
2274
|
-
"react-doctor/rerender-functional-setstate": "warn",
|
|
2275
|
-
"react-doctor/rerender-dependencies": "error",
|
|
2276
|
-
"react-doctor/rerender-state-only-in-handlers": "warn",
|
|
2277
|
-
"react-doctor/rerender-defer-reads-hook": "warn",
|
|
2278
|
-
"react-doctor/advanced-event-handler-refs": "warn",
|
|
2279
|
-
"react-doctor/effect-needs-cleanup": "error",
|
|
2280
|
-
"react-doctor/no-giant-component": "warn",
|
|
2281
|
-
"react-doctor/no-render-in-render": "warn",
|
|
2282
|
-
"react-doctor/no-many-boolean-props": "warn",
|
|
2283
|
-
"react-doctor/no-react19-deprecated-apis": "warn",
|
|
2284
|
-
"react-doctor/no-render-prop-children": "warn",
|
|
2285
|
-
"react-doctor/no-nested-component-definition": "error",
|
|
2286
|
-
"react-doctor/react-compiler-destructure-method": "warn",
|
|
2287
|
-
"react-doctor/no-legacy-class-lifecycles": "error",
|
|
2288
|
-
"react-doctor/no-legacy-context-api": "error",
|
|
2289
|
-
"react-doctor/no-default-props": "warn",
|
|
2290
|
-
"react-doctor/no-react-dom-deprecated-apis": "warn",
|
|
2291
|
-
"react-doctor/no-usememo-simple-expression": "warn",
|
|
2292
|
-
"react-doctor/no-layout-property-animation": "error",
|
|
2293
|
-
"react-doctor/rerender-memo-with-default-value": "warn",
|
|
2294
|
-
"react-doctor/rerender-memo-before-early-return": "warn",
|
|
2295
|
-
"react-doctor/rerender-transitions-scroll": "warn",
|
|
2296
|
-
"react-doctor/rerender-derived-state-from-hook": "warn",
|
|
2297
|
-
"react-doctor/async-defer-await": "warn",
|
|
2298
|
-
"react-doctor/async-await-in-loop": "warn",
|
|
2299
|
-
"react-doctor/rendering-animate-svg-wrapper": "warn",
|
|
2300
|
-
"react-doctor/rendering-hoist-jsx": "warn",
|
|
2301
|
-
"react-doctor/rendering-hydration-mismatch-time": "warn",
|
|
2302
|
-
"react-doctor/no-inline-prop-on-memo-component": "warn",
|
|
2303
|
-
"react-doctor/rendering-hydration-no-flicker": "warn",
|
|
2304
|
-
"react-doctor/rendering-script-defer-async": "warn",
|
|
2305
|
-
"react-doctor/rendering-usetransition-loading": "warn",
|
|
2306
|
-
"react-doctor/no-transition-all": "warn",
|
|
2307
|
-
"react-doctor/no-global-css-variable-animation": "error",
|
|
2308
|
-
"react-doctor/no-large-animated-blur": "warn",
|
|
2309
|
-
"react-doctor/no-scale-from-zero": "warn",
|
|
2310
|
-
"react-doctor/no-permanent-will-change": "warn",
|
|
2311
|
-
"react-doctor/no-eval": "error",
|
|
2312
|
-
"react-doctor/no-secrets-in-client-code": "warn",
|
|
2313
|
-
"react-doctor/no-generic-handler-names": "warn",
|
|
2314
|
-
"react-doctor/js-flatmap-filter": "warn",
|
|
2315
|
-
"react-doctor/js-combine-iterations": "warn",
|
|
2316
|
-
"react-doctor/js-tosorted-immutable": "warn",
|
|
2317
|
-
"react-doctor/js-hoist-regexp": "warn",
|
|
2318
|
-
"react-doctor/js-hoist-intl": "warn",
|
|
2319
|
-
"react-doctor/js-cache-property-access": "warn",
|
|
2320
|
-
"react-doctor/js-length-check-first": "warn",
|
|
2321
|
-
"react-doctor/js-min-max-loop": "warn",
|
|
2322
|
-
"react-doctor/js-set-map-lookups": "warn",
|
|
2323
|
-
"react-doctor/js-batch-dom-css": "warn",
|
|
2324
|
-
"react-doctor/js-index-maps": "warn",
|
|
2325
|
-
"react-doctor/js-cache-storage": "warn",
|
|
2326
|
-
"react-doctor/js-early-exit": "warn",
|
|
2327
|
-
"react-doctor/no-barrel-import": "warn",
|
|
2328
|
-
"react-doctor/no-dynamic-import-path": "warn",
|
|
2329
|
-
"react-doctor/no-full-lodash-import": "warn",
|
|
2330
|
-
"react-doctor/no-moment": "warn",
|
|
2331
|
-
"react-doctor/prefer-dynamic-import": "warn",
|
|
2332
|
-
"react-doctor/use-lazy-motion": "warn",
|
|
2333
|
-
"react-doctor/no-undeferred-third-party": "warn",
|
|
2334
|
-
"react-doctor/no-array-index-as-key": "warn",
|
|
2335
|
-
"react-doctor/no-polymorphic-children": "warn",
|
|
2336
|
-
"react-doctor/rendering-conditional-render": "warn",
|
|
2337
|
-
"react-doctor/rendering-svg-precision": "warn",
|
|
2338
|
-
"react-doctor/no-prevent-default": "warn",
|
|
2339
|
-
"react-doctor/no-uncontrolled-input": "warn",
|
|
2340
|
-
"react-doctor/no-document-start-view-transition": "warn",
|
|
2341
|
-
"react-doctor/no-flush-sync": "warn",
|
|
2342
|
-
"react-doctor/server-auth-actions": "error",
|
|
2343
|
-
"react-doctor/server-after-nonblocking": "warn",
|
|
2344
|
-
"react-doctor/server-no-mutable-module-state": "error",
|
|
2345
|
-
"react-doctor/server-cache-with-object-literal": "warn",
|
|
2346
|
-
"react-doctor/server-hoist-static-io": "warn",
|
|
2347
|
-
"react-doctor/server-dedup-props": "warn",
|
|
2348
|
-
"react-doctor/server-sequential-independent-await": "warn",
|
|
2349
|
-
"react-doctor/server-fetch-without-revalidate": "warn",
|
|
2350
|
-
"react-doctor/client-passive-event-listeners": "warn",
|
|
2351
|
-
"react-doctor/client-localstorage-no-version": "warn",
|
|
2352
|
-
"react-doctor/no-inline-bounce-easing": "warn",
|
|
2353
|
-
"react-doctor/no-z-index-9999": "warn",
|
|
2354
|
-
"react-doctor/no-inline-exhaustive-style": "warn",
|
|
2355
|
-
"react-doctor/no-side-tab-border": "warn",
|
|
2356
|
-
"react-doctor/no-pure-black-background": "warn",
|
|
2357
|
-
"react-doctor/no-gradient-text": "warn",
|
|
2358
|
-
"react-doctor/no-dark-mode-glow": "warn",
|
|
2359
|
-
"react-doctor/no-justified-text": "warn",
|
|
2360
|
-
"react-doctor/no-tiny-text": "warn",
|
|
2361
|
-
"react-doctor/no-wide-letter-spacing": "warn",
|
|
2362
|
-
"react-doctor/no-gray-on-colored-background": "warn",
|
|
2363
|
-
"react-doctor/no-layout-transition-inline": "warn",
|
|
2364
|
-
"react-doctor/no-disabled-zoom": "error",
|
|
2365
|
-
"react-doctor/no-outline-none": "warn",
|
|
2366
|
-
"react-doctor/no-long-transition-duration": "warn",
|
|
2367
|
-
"react-doctor/design-no-bold-heading": "warn",
|
|
2368
|
-
"react-doctor/design-no-redundant-padding-axes": "warn",
|
|
2369
|
-
"react-doctor/design-no-redundant-size-axes": "warn",
|
|
2370
|
-
"react-doctor/design-no-space-on-flex-children": "warn",
|
|
2371
|
-
"react-doctor/design-no-em-dash-in-jsx-text": "warn",
|
|
2372
|
-
"react-doctor/design-no-three-period-ellipsis": "warn",
|
|
2373
|
-
"react-doctor/design-no-default-tailwind-palette": "warn",
|
|
2374
|
-
"react-doctor/design-no-vague-button-label": "warn",
|
|
2375
|
-
"react-doctor/async-parallel": "warn"
|
|
2376
|
-
};
|
|
2377
|
-
const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
2378
|
-
...Object.keys(GLOBAL_REACT_DOCTOR_RULES),
|
|
2379
|
-
...Object.keys(NEXTJS_RULES),
|
|
2380
|
-
...Object.keys(REACT_NATIVE_RULES),
|
|
2381
|
-
...Object.keys(TANSTACK_START_RULES),
|
|
2382
|
-
...Object.keys(TANSTACK_QUERY_RULES)
|
|
2383
|
-
]);
|
|
2384
|
-
const VERSION_GATED_RULE_IDS = new Map([
|
|
2385
|
-
["react-doctor/no-react19-deprecated-apis", {
|
|
2386
|
-
minMajor: 19,
|
|
2387
|
-
mode: "deprecation-warning"
|
|
2388
|
-
}],
|
|
2389
|
-
["react-doctor/no-default-props", {
|
|
2390
|
-
minMajor: 19,
|
|
2391
|
-
mode: "deprecation-warning"
|
|
2392
|
-
}],
|
|
2393
|
-
["react-doctor/no-react-dom-deprecated-apis", {
|
|
2394
|
-
minMajor: 18,
|
|
2395
|
-
mode: "deprecation-warning"
|
|
2396
|
-
}],
|
|
2397
|
-
["react-doctor/prefer-use-effect-event", {
|
|
2398
|
-
minMajor: 19,
|
|
2399
|
-
mode: "prefer-newer-api"
|
|
2400
|
-
}]
|
|
2401
|
-
]);
|
|
2402
|
-
const filterRulesByReactMajor = (rules, reactMajorVersion) => {
|
|
2403
|
-
return Object.fromEntries(Object.entries(rules).filter(([ruleKey]) => {
|
|
2404
|
-
const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
|
|
2405
|
-
if (gate === void 0) return true;
|
|
2406
|
-
if (gate.mode === "deprecation-warning") return true;
|
|
2407
|
-
if (reactMajorVersion === null) return true;
|
|
2408
|
-
return reactMajorVersion >= gate.minMajor;
|
|
2409
|
-
}));
|
|
2410
|
-
};
|
|
2411
|
-
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
|
|
2412
|
-
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
2413
|
-
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
2414
|
-
const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
|
|
2415
|
-
const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin ? filterRulesToAvailable(YOU_MIGHT_NOT_NEED_EFFECT_RULES, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, youMightNotNeedEffectPlugin.availableRuleNames) : {};
|
|
2416
|
-
const jsPlugins = [];
|
|
2417
|
-
if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
|
|
2418
|
-
if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);
|
|
2419
|
-
return {
|
|
2420
|
-
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
2421
|
-
categories: {
|
|
2422
|
-
correctness: "off",
|
|
2423
|
-
suspicious: "off",
|
|
2424
|
-
pedantic: "off",
|
|
2425
|
-
perf: "off",
|
|
2426
|
-
restriction: "off",
|
|
2427
|
-
style: "off",
|
|
2428
|
-
nursery: "off"
|
|
2429
|
-
},
|
|
2430
|
-
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
2431
|
-
jsPlugins: [...jsPlugins, pluginPath],
|
|
2432
|
-
rules: {
|
|
2433
|
-
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
2434
|
-
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
2435
|
-
...reactCompilerRules,
|
|
2436
|
-
...youMightNotNeedEffectRules,
|
|
2437
|
-
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
2438
|
-
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
2439
|
-
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
2440
|
-
...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
|
|
2441
|
-
...hasTanStackQuery ? TANSTACK_QUERY_RULES : {}
|
|
2442
|
-
}
|
|
2443
|
-
};
|
|
2444
|
-
};
|
|
2445
|
-
//#endregion
|
|
2446
|
-
//#region src/utils/neutralize-disable-directives.ts
|
|
2447
|
-
const DISABLE_DIRECTIVE_PATTERN = /(eslint|oxlint)-disable/;
|
|
2448
|
-
const findFilesWithDisableDirectivesViaGit = (rootDirectory, includePaths) => {
|
|
2449
|
-
const grepArgs = [
|
|
2450
|
-
"grep",
|
|
2451
|
-
"-l",
|
|
2452
|
-
"--untracked",
|
|
2453
|
-
"-E",
|
|
2454
|
-
"(eslint|oxlint)-disable"
|
|
2455
|
-
];
|
|
2456
|
-
if (includePaths && includePaths.length > 0) grepArgs.push("--", ...includePaths);
|
|
2457
|
-
const result = spawnSync("git", grepArgs, {
|
|
2458
|
-
cwd: rootDirectory,
|
|
2459
|
-
encoding: "utf-8",
|
|
2460
|
-
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
2461
|
-
});
|
|
2462
|
-
if (result.error || result.status === null) return null;
|
|
2463
|
-
if (result.status === 128) return null;
|
|
2464
|
-
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
|
|
2465
|
-
};
|
|
2466
|
-
const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
|
|
2467
|
-
const matches = [];
|
|
2468
|
-
const checkFile = (relativePath) => {
|
|
2469
|
-
if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
|
|
2470
|
-
const absolutePath = path.join(rootDirectory, relativePath);
|
|
2471
|
-
let content;
|
|
2472
|
-
try {
|
|
2473
|
-
content = fs.readFileSync(absolutePath, "utf-8");
|
|
2474
|
-
} catch {
|
|
2475
|
-
return;
|
|
2476
|
-
}
|
|
2477
|
-
if (DISABLE_DIRECTIVE_PATTERN.test(content)) matches.push(relativePath);
|
|
2478
|
-
};
|
|
2479
|
-
if (includePaths && includePaths.length > 0) {
|
|
2480
|
-
for (const candidate of includePaths) checkFile(candidate);
|
|
2481
|
-
return matches;
|
|
2482
|
-
}
|
|
2483
|
-
const stack = [rootDirectory];
|
|
2484
|
-
while (stack.length > 0) {
|
|
2485
|
-
const current = stack.pop();
|
|
2486
|
-
if (current === void 0) continue;
|
|
2487
|
-
let entries;
|
|
2488
|
-
try {
|
|
2489
|
-
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
2490
|
-
} catch {
|
|
2491
|
-
continue;
|
|
2492
|
-
}
|
|
2493
|
-
for (const entry of entries) {
|
|
2494
|
-
if (entry.isDirectory()) {
|
|
2495
|
-
if (entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
2496
|
-
stack.push(path.join(current, entry.name));
|
|
2497
|
-
continue;
|
|
2498
|
-
}
|
|
2499
|
-
if (!entry.isFile()) continue;
|
|
2500
|
-
const absolute = path.join(current, entry.name);
|
|
2501
|
-
checkFile(path.relative(rootDirectory, absolute));
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
return matches;
|
|
2505
|
-
};
|
|
2506
|
-
const findFilesWithDisableDirectives = (rootDirectory, includePaths) => findFilesWithDisableDirectivesViaGit(rootDirectory, includePaths) ?? findFilesWithDisableDirectivesViaFilesystem(rootDirectory, includePaths);
|
|
2507
|
-
const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
|
|
2508
|
-
const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
|
|
2509
|
-
const filePaths = findFilesWithDisableDirectives(rootDirectory, includePaths);
|
|
2510
|
-
const originalContents = /* @__PURE__ */ new Map();
|
|
2511
|
-
let isRestored = false;
|
|
2512
|
-
const restore = () => {
|
|
2513
|
-
if (isRestored) return;
|
|
2514
|
-
isRestored = true;
|
|
2515
|
-
for (const [absolutePath, originalContent] of originalContents) try {
|
|
2516
|
-
fs.writeFileSync(absolutePath, originalContent);
|
|
2517
|
-
} catch {}
|
|
2518
|
-
};
|
|
2519
|
-
const onExit = () => restore();
|
|
2520
|
-
process.once("exit", onExit);
|
|
2521
|
-
for (const relativePath of filePaths) {
|
|
2522
|
-
const absolutePath = path.join(rootDirectory, relativePath);
|
|
2523
|
-
let originalContent;
|
|
2524
|
-
try {
|
|
2525
|
-
originalContent = fs.readFileSync(absolutePath, "utf-8");
|
|
2526
|
-
} catch {
|
|
2527
|
-
continue;
|
|
2528
|
-
}
|
|
2529
|
-
const neutralizedContent = neutralizeContent(originalContent);
|
|
2530
|
-
if (neutralizedContent !== originalContent) {
|
|
2531
|
-
originalContents.set(absolutePath, originalContent);
|
|
2532
|
-
fs.writeFileSync(absolutePath, neutralizedContent);
|
|
2533
|
-
}
|
|
2534
|
-
}
|
|
2535
|
-
return () => {
|
|
2536
|
-
restore();
|
|
2537
|
-
process.removeListener("exit", onExit);
|
|
2538
|
-
};
|
|
2539
|
-
};
|
|
2540
|
-
//#endregion
|
|
2541
|
-
//#region src/utils/run-oxlint.ts
|
|
2542
|
-
const esmRequire = createRequire(import.meta.url);
|
|
2543
|
-
const PLUGIN_CATEGORY_MAP = {
|
|
2544
|
-
react: "Correctness",
|
|
2545
|
-
"react-hooks": "Correctness",
|
|
2546
|
-
"react-hooks-js": "React Compiler",
|
|
2547
|
-
"react-doctor": "Other",
|
|
2548
|
-
"jsx-a11y": "Accessibility",
|
|
2549
|
-
knip: "Dead Code",
|
|
2550
|
-
effect: "State & Effects",
|
|
2551
|
-
eslint: "Correctness",
|
|
2552
|
-
oxc: "Correctness",
|
|
2553
|
-
typescript: "Correctness",
|
|
2554
|
-
unicorn: "Correctness",
|
|
2555
|
-
import: "Bundle Size",
|
|
2556
|
-
promise: "Correctness",
|
|
2557
|
-
n: "Correctness",
|
|
2558
|
-
node: "Correctness",
|
|
2559
|
-
vitest: "Correctness",
|
|
2560
|
-
jest: "Correctness",
|
|
2561
|
-
nextjs: "Next.js"
|
|
2562
|
-
};
|
|
2563
|
-
const RULE_CATEGORY_MAP = {
|
|
2564
|
-
"react-doctor/no-derived-state-effect": "State & Effects",
|
|
2565
|
-
"react-doctor/no-fetch-in-effect": "State & Effects",
|
|
2566
|
-
"react-doctor/no-mirror-prop-effect": "State & Effects",
|
|
2567
|
-
"react-doctor/no-mutable-in-deps": "State & Effects",
|
|
2568
|
-
"react-doctor/no-cascading-set-state": "State & Effects",
|
|
2569
|
-
"react-doctor/no-effect-chain": "State & Effects",
|
|
2570
|
-
"react-doctor/no-effect-event-handler": "State & Effects",
|
|
2571
|
-
"react-doctor/no-effect-event-in-deps": "State & Effects",
|
|
2572
|
-
"react-doctor/no-event-trigger-state": "State & Effects",
|
|
2573
|
-
"react-doctor/no-prop-callback-in-effect": "State & Effects",
|
|
2574
|
-
"react-doctor/no-derived-useState": "State & Effects",
|
|
2575
|
-
"react-doctor/no-direct-state-mutation": "State & Effects",
|
|
2576
|
-
"react-doctor/no-set-state-in-render": "State & Effects",
|
|
2577
|
-
"react-doctor/prefer-use-effect-event": "State & Effects",
|
|
2578
|
-
"react-doctor/prefer-useReducer": "State & Effects",
|
|
2579
|
-
"react-doctor/prefer-use-sync-external-store": "State & Effects",
|
|
2580
|
-
"react-doctor/rerender-lazy-state-init": "Performance",
|
|
2581
|
-
"react-doctor/rerender-functional-setstate": "Performance",
|
|
2582
|
-
"react-doctor/rerender-dependencies": "State & Effects",
|
|
2583
|
-
"react-doctor/rerender-state-only-in-handlers": "Performance",
|
|
2584
|
-
"react-doctor/rerender-defer-reads-hook": "Performance",
|
|
2585
|
-
"react-doctor/advanced-event-handler-refs": "Performance",
|
|
2586
|
-
"react-doctor/effect-needs-cleanup": "State & Effects",
|
|
2587
|
-
"react-doctor/no-generic-handler-names": "Architecture",
|
|
2588
|
-
"react-doctor/no-giant-component": "Architecture",
|
|
2589
|
-
"react-doctor/no-many-boolean-props": "Architecture",
|
|
2590
|
-
"react-doctor/no-react19-deprecated-apis": "Architecture",
|
|
2591
|
-
"react-doctor/no-render-prop-children": "Architecture",
|
|
2592
|
-
"react-doctor/no-render-in-render": "Architecture",
|
|
2593
|
-
"react-doctor/no-nested-component-definition": "Correctness",
|
|
2594
|
-
"react-doctor/react-compiler-destructure-method": "Architecture",
|
|
2595
|
-
"react-doctor/no-legacy-class-lifecycles": "Correctness",
|
|
2596
|
-
"react-doctor/no-legacy-context-api": "Correctness",
|
|
2597
|
-
"react-doctor/no-default-props": "Architecture",
|
|
2598
|
-
"react-doctor/no-react-dom-deprecated-apis": "Architecture",
|
|
2599
|
-
"react-doctor/no-usememo-simple-expression": "Performance",
|
|
2600
|
-
"react-doctor/no-layout-property-animation": "Performance",
|
|
2601
|
-
"react-doctor/rerender-memo-with-default-value": "Performance",
|
|
2602
|
-
"react-doctor/rerender-memo-before-early-return": "Performance",
|
|
2603
|
-
"react-doctor/rerender-transitions-scroll": "Performance",
|
|
2604
|
-
"react-doctor/rerender-derived-state-from-hook": "Performance",
|
|
2605
|
-
"react-doctor/async-defer-await": "Performance",
|
|
2606
|
-
"react-doctor/async-await-in-loop": "Performance",
|
|
2607
|
-
"react-doctor/rendering-animate-svg-wrapper": "Performance",
|
|
2608
|
-
"react-doctor/rendering-hoist-jsx": "Performance",
|
|
2609
|
-
"react-doctor/rendering-hydration-mismatch-time": "Correctness",
|
|
2610
|
-
"react-doctor/rendering-usetransition-loading": "Performance",
|
|
2611
|
-
"react-doctor/rendering-hydration-no-flicker": "Performance",
|
|
2612
|
-
"react-doctor/rendering-script-defer-async": "Performance",
|
|
2613
|
-
"react-doctor/no-inline-prop-on-memo-component": "Performance",
|
|
2614
|
-
"react-doctor/no-transition-all": "Performance",
|
|
2615
|
-
"react-doctor/no-global-css-variable-animation": "Performance",
|
|
2616
|
-
"react-doctor/no-large-animated-blur": "Performance",
|
|
2617
|
-
"react-doctor/no-scale-from-zero": "Performance",
|
|
2618
|
-
"react-doctor/no-permanent-will-change": "Performance",
|
|
2619
|
-
"react-doctor/no-secrets-in-client-code": "Security",
|
|
2620
|
-
"react-doctor/no-barrel-import": "Bundle Size",
|
|
2621
|
-
"react-doctor/no-dynamic-import-path": "Bundle Size",
|
|
2622
|
-
"react-doctor/no-full-lodash-import": "Bundle Size",
|
|
2623
|
-
"react-doctor/no-moment": "Bundle Size",
|
|
2624
|
-
"react-doctor/prefer-dynamic-import": "Bundle Size",
|
|
2625
|
-
"react-doctor/use-lazy-motion": "Bundle Size",
|
|
2626
|
-
"react-doctor/no-undeferred-third-party": "Bundle Size",
|
|
2627
|
-
"react-doctor/no-array-index-as-key": "Correctness",
|
|
2628
|
-
"react-doctor/no-polymorphic-children": "Architecture",
|
|
2629
|
-
"react-doctor/rendering-conditional-render": "Correctness",
|
|
2630
|
-
"react-doctor/rendering-svg-precision": "Performance",
|
|
2631
|
-
"react-doctor/no-prevent-default": "Correctness",
|
|
2632
|
-
"react-doctor/no-uncontrolled-input": "Correctness",
|
|
2633
|
-
"react-doctor/no-document-start-view-transition": "Correctness",
|
|
2634
|
-
"react-doctor/no-flush-sync": "Performance",
|
|
2635
|
-
"react-doctor/nextjs-no-img-element": "Next.js",
|
|
2636
|
-
"react-doctor/nextjs-async-client-component": "Next.js",
|
|
2637
|
-
"react-doctor/nextjs-no-a-element": "Next.js",
|
|
2638
|
-
"react-doctor/nextjs-no-use-search-params-without-suspense": "Next.js",
|
|
2639
|
-
"react-doctor/nextjs-no-client-fetch-for-server-data": "Next.js",
|
|
2640
|
-
"react-doctor/nextjs-missing-metadata": "Next.js",
|
|
2641
|
-
"react-doctor/nextjs-no-client-side-redirect": "Next.js",
|
|
2642
|
-
"react-doctor/nextjs-no-redirect-in-try-catch": "Next.js",
|
|
2643
|
-
"react-doctor/nextjs-image-missing-sizes": "Next.js",
|
|
2644
|
-
"react-doctor/nextjs-no-native-script": "Next.js",
|
|
2645
|
-
"react-doctor/nextjs-inline-script-missing-id": "Next.js",
|
|
2646
|
-
"react-doctor/nextjs-no-font-link": "Next.js",
|
|
2647
|
-
"react-doctor/nextjs-no-css-link": "Next.js",
|
|
2648
|
-
"react-doctor/nextjs-no-polyfill-script": "Next.js",
|
|
2649
|
-
"react-doctor/nextjs-no-head-import": "Next.js",
|
|
2650
|
-
"react-doctor/nextjs-no-side-effect-in-get-handler": "Security",
|
|
2651
|
-
"react-doctor/server-auth-actions": "Server",
|
|
2652
|
-
"react-doctor/server-after-nonblocking": "Server",
|
|
2653
|
-
"react-doctor/server-no-mutable-module-state": "Server",
|
|
2654
|
-
"react-doctor/server-cache-with-object-literal": "Server",
|
|
2655
|
-
"react-doctor/server-hoist-static-io": "Server",
|
|
2656
|
-
"react-doctor/server-dedup-props": "Server",
|
|
2657
|
-
"react-doctor/server-sequential-independent-await": "Server",
|
|
2658
|
-
"react-doctor/server-fetch-without-revalidate": "Server",
|
|
2659
|
-
"react-doctor/client-passive-event-listeners": "Performance",
|
|
2660
|
-
"react-doctor/client-localstorage-no-version": "Correctness",
|
|
2661
|
-
"react-doctor/query-stable-query-client": "TanStack Query",
|
|
2662
|
-
"react-doctor/query-no-rest-destructuring": "TanStack Query",
|
|
2663
|
-
"react-doctor/query-no-void-query-fn": "TanStack Query",
|
|
2664
|
-
"react-doctor/query-no-query-in-effect": "TanStack Query",
|
|
2665
|
-
"react-doctor/query-mutation-missing-invalidation": "TanStack Query",
|
|
2666
|
-
"react-doctor/query-no-usequery-for-mutation": "TanStack Query",
|
|
2667
|
-
"react-doctor/no-inline-bounce-easing": "Performance",
|
|
2668
|
-
"react-doctor/no-z-index-9999": "Architecture",
|
|
2669
|
-
"react-doctor/no-inline-exhaustive-style": "Architecture",
|
|
2670
|
-
"react-doctor/no-side-tab-border": "Architecture",
|
|
2671
|
-
"react-doctor/no-pure-black-background": "Architecture",
|
|
2672
|
-
"react-doctor/no-gradient-text": "Architecture",
|
|
2673
|
-
"react-doctor/no-dark-mode-glow": "Architecture",
|
|
2674
|
-
"react-doctor/no-justified-text": "Accessibility",
|
|
2675
|
-
"react-doctor/no-tiny-text": "Accessibility",
|
|
2676
|
-
"react-doctor/no-wide-letter-spacing": "Architecture",
|
|
2677
|
-
"react-doctor/no-gray-on-colored-background": "Accessibility",
|
|
2678
|
-
"react-doctor/no-layout-transition-inline": "Performance",
|
|
2679
|
-
"react-doctor/no-disabled-zoom": "Accessibility",
|
|
2680
|
-
"react-doctor/no-outline-none": "Accessibility",
|
|
2681
|
-
"react-doctor/no-long-transition-duration": "Performance",
|
|
2682
|
-
"react-doctor/design-no-bold-heading": "Architecture",
|
|
2683
|
-
"react-doctor/design-no-redundant-padding-axes": "Architecture",
|
|
2684
|
-
"react-doctor/design-no-redundant-size-axes": "Architecture",
|
|
2685
|
-
"react-doctor/design-no-space-on-flex-children": "Architecture",
|
|
2686
|
-
"react-doctor/design-no-em-dash-in-jsx-text": "Architecture",
|
|
2687
|
-
"react-doctor/design-no-three-period-ellipsis": "Architecture",
|
|
2688
|
-
"react-doctor/design-no-default-tailwind-palette": "Architecture",
|
|
2689
|
-
"react-doctor/design-no-vague-button-label": "Accessibility",
|
|
2690
|
-
"react-doctor/js-flatmap-filter": "Performance",
|
|
2691
|
-
"react-doctor/js-combine-iterations": "Performance",
|
|
2692
|
-
"react-doctor/js-tosorted-immutable": "Performance",
|
|
2693
|
-
"react-doctor/js-hoist-regexp": "Performance",
|
|
2694
|
-
"react-doctor/js-hoist-intl": "Performance",
|
|
2695
|
-
"react-doctor/js-cache-property-access": "Performance",
|
|
2696
|
-
"react-doctor/js-length-check-first": "Performance",
|
|
2697
|
-
"react-doctor/js-min-max-loop": "Performance",
|
|
2698
|
-
"react-doctor/js-set-map-lookups": "Performance",
|
|
2699
|
-
"react-doctor/js-batch-dom-css": "Performance",
|
|
2700
|
-
"react-doctor/js-index-maps": "Performance",
|
|
2701
|
-
"react-doctor/js-cache-storage": "Performance",
|
|
2702
|
-
"react-doctor/js-early-exit": "Performance",
|
|
2703
|
-
"react-doctor/no-eval": "Security",
|
|
2704
|
-
"react-doctor/async-parallel": "Performance",
|
|
2705
|
-
"react-doctor/rn-no-raw-text": "React Native",
|
|
2706
|
-
"react-doctor/rn-no-deprecated-modules": "React Native",
|
|
2707
|
-
"react-doctor/rn-no-legacy-expo-packages": "React Native",
|
|
2708
|
-
"react-doctor/rn-no-dimensions-get": "React Native",
|
|
2709
|
-
"react-doctor/rn-no-inline-flatlist-renderitem": "React Native",
|
|
2710
|
-
"react-doctor/rn-no-legacy-shadow-styles": "React Native",
|
|
2711
|
-
"react-doctor/rn-prefer-reanimated": "React Native",
|
|
2712
|
-
"react-doctor/rn-no-single-element-style-array": "React Native",
|
|
2713
|
-
"react-doctor/rn-prefer-pressable": "React Native",
|
|
2714
|
-
"react-doctor/rn-prefer-expo-image": "React Native",
|
|
2715
|
-
"react-doctor/rn-no-non-native-navigator": "React Native",
|
|
2716
|
-
"react-doctor/rn-no-scroll-state": "React Native",
|
|
2717
|
-
"react-doctor/rn-no-scrollview-mapped-list": "React Native",
|
|
2718
|
-
"react-doctor/rn-no-inline-object-in-list-item": "React Native",
|
|
2719
|
-
"react-doctor/rn-animate-layout-property": "React Native",
|
|
2720
|
-
"react-doctor/rn-prefer-content-inset-adjustment": "React Native",
|
|
2721
|
-
"react-doctor/rn-pressable-shared-value-mutation": "React Native",
|
|
2722
|
-
"react-doctor/rn-list-data-mapped": "React Native",
|
|
2723
|
-
"react-doctor/rn-list-callback-per-row": "React Native",
|
|
2724
|
-
"react-doctor/rn-list-recyclable-without-types": "React Native",
|
|
2725
|
-
"react-doctor/rn-animation-reaction-as-derived": "React Native",
|
|
2726
|
-
"react-doctor/rn-bottom-sheet-prefer-native": "React Native",
|
|
2727
|
-
"react-doctor/rn-scrollview-dynamic-padding": "React Native",
|
|
2728
|
-
"react-doctor/rn-style-prefer-boxshadow": "React Native",
|
|
2729
|
-
"react-doctor/tanstack-start-route-property-order": "TanStack Start",
|
|
2730
|
-
"react-doctor/tanstack-start-no-direct-fetch-in-loader": "TanStack Start",
|
|
2731
|
-
"react-doctor/tanstack-start-server-fn-validate-input": "TanStack Start",
|
|
2732
|
-
"react-doctor/tanstack-start-no-useeffect-fetch": "TanStack Start",
|
|
2733
|
-
"react-doctor/tanstack-start-missing-head-content": "TanStack Start",
|
|
2734
|
-
"react-doctor/tanstack-start-no-anchor-element": "TanStack Start",
|
|
2735
|
-
"react-doctor/tanstack-start-server-fn-method-order": "TanStack Start",
|
|
2736
|
-
"react-doctor/tanstack-start-no-navigate-in-render": "TanStack Start",
|
|
2737
|
-
"react-doctor/tanstack-start-no-dynamic-server-fn-import": "TanStack Start",
|
|
2738
|
-
"react-doctor/tanstack-start-no-use-server-in-handler": "TanStack Start",
|
|
2739
|
-
"react-doctor/tanstack-start-no-secrets-in-loader": "Security",
|
|
2740
|
-
"react-doctor/tanstack-start-get-mutation": "Security",
|
|
2741
|
-
"react-doctor/tanstack-start-redirect-in-try-catch": "TanStack Start",
|
|
2742
|
-
"react-doctor/tanstack-start-loader-parallel-fetch": "Performance"
|
|
2743
|
-
};
|
|
2744
|
-
const RULE_HELP_MAP = {
|
|
2745
|
-
"no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`. See https://react.dev/learn/you-might-not-need-an-effect",
|
|
2746
|
-
"no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
|
|
2747
|
-
"no-mirror-prop-effect": "Delete both the `useState` and the `useEffect` and read the prop directly during render. Mirroring a prop into local state forces a stale first render before the effect re-syncs",
|
|
2748
|
-
"no-mutable-in-deps": "Read mutable values (`location.pathname`, `ref.current`) inside the effect body instead of in the deps array, or subscribe with `useSyncExternalStore`. Mutations to these don't trigger re-renders, so listing them in deps doesn't make the effect react to changes",
|
|
2749
|
-
"no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
|
|
2750
|
-
"no-effect-chain": "Compute as much as possible during render (e.g. `const isGameOver = round > 5`) and write all related state inside the event handler that originally fires the chain. Each effect link adds an extra render and makes the code rigid as requirements evolve",
|
|
2751
|
-
"no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
|
|
2752
|
-
"no-event-trigger-state": "Delete the trigger state (`useState(null)` plus the `useEffect` that watches it) and call the side-effect (`post(...)` / `navigate(...)` / `track(...)`) directly inside the event handler that previously called the setter. State should not exist purely to schedule effect runs",
|
|
2753
|
-
"no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
|
|
2754
|
-
"no-direct-state-mutation": "Replace the mutation with a setter call that produces a new reference: `setItems([...items, newItem])`, `setItems(items.filter(x => x !== target))`, `setItems(items.toSorted(...))`. React only re-renders on a new reference, so in-place updates are silently dropped",
|
|
2755
|
-
"no-set-state-in-render": "Move the setter call into a `useEffect`, an event handler, or replace the state with a value computed during render. Calling a setter at render time triggers another render, which calls the setter again — an infinite loop",
|
|
2756
|
-
"prefer-use-effect-event": "Wrap the callback with `useEffectEvent(callback)` (React 19+) and call the resulting binding from inside the sub-handler. The Effect Event captures the latest props/state without being a reactive dep, so the effect doesn't re-subscribe on every parent render. See https://react.dev/reference/react/useEffectEvent",
|
|
2757
|
-
"prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
|
|
2758
|
-
"prefer-use-sync-external-store": "Replace the `useState(getSnapshot())` + `useEffect(() => store.subscribe(() => setSnapshot(getSnapshot())))` pair with `useSyncExternalStore(store.subscribe, getSnapshot)`. The hook handles tearing during concurrent renders and SSR snapshots; the manual subscribe pattern doesn't",
|
|
2759
|
-
"rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
|
|
2760
|
-
"rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
|
|
2761
|
-
"rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
|
|
2762
|
-
"no-effect-event-in-deps": "Call the useEffectEvent callback inside the effect body without listing it; its identity is intentionally unstable",
|
|
2763
|
-
"no-prop-callback-in-effect": "Lift the shared state into a Provider so both sides read the same source — no useEffect-driven sync needed",
|
|
2764
|
-
"no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
|
|
2765
|
-
"no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
|
|
2766
|
-
"no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
|
|
2767
|
-
"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. Only enabled on projects detected as React 19+.",
|
|
2768
|
-
"no-legacy-class-lifecycles": "Move side effects in `componentWillMount` to `componentDidMount`; replace `componentWillReceiveProps` with `componentDidUpdate` (compare prevProps) or the static `getDerivedStateFromProps` for pure state derivation; replace `componentWillUpdate` with `getSnapshotBeforeUpdate` paired with `componentDidUpdate`. The `UNSAFE_` prefix only silences the warning — React 19 removes both forms.",
|
|
2769
|
-
"no-legacy-context-api": "Replace `childContextTypes` + `getChildContext` with `const MyContext = createContext(...)` + `<MyContext.Provider value={...}>`; replace `contextTypes` with `static contextType = MyContext` (single context) or `useContext()` / `use()` from a function component. The provider and every consumer must migrate together — partial migrations leave consumers reading the wrong context.",
|
|
2770
|
-
"no-default-props": "React 19 removes `Component.defaultProps` for function components. Move the defaults into the destructured props parameter: `function Foo({ size = \"md\", variant = \"primary\" })` instead of `Foo.defaultProps = { size: \"md\", variant: \"primary\" }`.",
|
|
2771
|
-
"no-react-dom-deprecated-apis": "Switch the legacy `react-dom` root API (`render` / `hydrate` / `unmountComponentAtNode`) to `createRoot` / `hydrateRoot` / `root.unmount()` from `react-dom/client`. Replace `findDOMNode` with a ref. The whole `react-dom/test-utils` entry point is removed in React 19 — use `act` from `react` and `fireEvent` / `render` from `@testing-library/react`. Only enabled on projects detected as React 18+.",
|
|
2772
|
-
"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",
|
|
2773
|
-
"no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
|
|
2774
|
-
"no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
|
|
2775
|
-
"no-usememo-simple-expression": "Remove useMemo — property access, math, and ternaries are already cheap without memoization",
|
|
2776
|
-
"no-layout-property-animation": "Use `transform: translateX()` or `scale()` instead — they run on the compositor and skip layout/paint",
|
|
2777
|
-
"rerender-memo-with-default-value": "Move to module scope: `const EMPTY_ITEMS: Item[] = []` then use as the default value",
|
|
2778
|
-
"rendering-animate-svg-wrapper": "Wrap the SVG: `<motion.div animate={...}><svg>...</svg></motion.div>`",
|
|
2779
|
-
"rendering-hoist-jsx": "Move the static JSX to module scope: `const ICON = <svg>...</svg>` outside the component so it isn't recreated each render",
|
|
2780
|
-
"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",
|
|
2781
|
-
"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",
|
|
2782
|
-
"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",
|
|
2783
|
-
"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",
|
|
2784
|
-
"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",
|
|
2785
|
-
"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",
|
|
2786
|
-
"effect-needs-cleanup": "Return a cleanup function that releases the subscription / timer: `return () => target.removeEventListener(name, handler)` for listeners, `return () => clearInterval(id)` / `clearTimeout(id)` for timers, or `return unsubscribe` if the subscribe call already returned one",
|
|
2787
|
-
"async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
|
|
2788
|
-
"async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
|
|
2789
|
-
"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",
|
|
2790
|
-
"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",
|
|
2791
|
-
"server-sequential-independent-await": "Wrap independent awaits in `Promise.all([...])` so they race instead of waterfalling — second call doesn't depend on the first",
|
|
2792
|
-
"server-fetch-without-revalidate": "Pass `{ next: { revalidate: <seconds> } }` (or `cache: \"no-store\"` / `next: { tags: [...] }`) so stale cached data doesn't silently persist",
|
|
2793
|
-
"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",
|
|
2794
|
-
"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",
|
|
2795
|
-
"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",
|
|
2796
|
-
"rendering-hydration-mismatch-time": "Wrap dynamic time/random values in useEffect+useState (client-only) or add suppressHydrationWarning to the parent if intentional",
|
|
2797
|
-
"no-polymorphic-children": "Expose explicit subcomponents (`<Button.Text>`, `<Button.Icon>`) so consumers don't need to switch on `typeof children`",
|
|
2798
|
-
"rendering-svg-precision": "Truncate path/points/transform decimals to 1–2 digits — sub-pixel precision adds bytes with no visible difference",
|
|
2799
|
-
"no-document-start-view-transition": "Render a <ViewTransition> component and update inside startTransition / useDeferredValue — React calls startViewTransition for you",
|
|
2800
|
-
"no-flush-sync": "Use startTransition for non-urgent updates — flushSync forces a sync flush that skips View Transitions and concurrent rendering",
|
|
2801
|
-
"rendering-usetransition-loading": "Replace with `const [isPending, startTransition] = useTransition()` — avoids a re-render for the loading state",
|
|
2802
|
-
"rendering-hydration-no-flicker": "Use `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)` or add `suppressHydrationWarning` to the element",
|
|
2803
|
-
"rendering-script-defer-async": "Add `defer` for DOM-dependent scripts or `async` for independent ones (analytics). In Next.js, use `<Script strategy=\"afterInteractive\" />` instead",
|
|
2804
|
-
"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",
|
|
2805
|
-
"no-transition-all": "List specific properties: `transition: \"opacity 200ms, transform 200ms\"` — or in Tailwind use `transition-colors`, `transition-opacity`, or `transition-transform`",
|
|
2806
|
-
"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",
|
|
2807
|
-
"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",
|
|
2808
|
-
"no-scale-from-zero": "Use `initial={{ scale: 0.95, opacity: 0 }}` — elements should deflate like a balloon, not vanish into a point",
|
|
2809
|
-
"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",
|
|
2810
|
-
"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)",
|
|
2811
|
-
"no-barrel-import": "Import from the direct path: `import { Button } from './components/Button'` instead of `./components`",
|
|
2812
|
-
"no-dynamic-import-path": "Use a string-literal path: `import('./feature/heavy.js')` so the bundler can split this chunk",
|
|
2813
|
-
"no-full-lodash-import": "Import the specific function: `import debounce from 'lodash/debounce'` — saves ~70kb",
|
|
2814
|
-
"no-moment": "Replace with `import { format } from 'date-fns'` (tree-shakeable) or `import dayjs from 'dayjs'` (2kb)",
|
|
2815
|
-
"prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
|
|
2816
|
-
"use-lazy-motion": "Use `import { LazyMotion, m } from \"framer-motion\"` with `domAnimation` features — saves ~30kb",
|
|
2817
|
-
"no-undeferred-third-party": "Use `next/script` with `strategy=\"lazyOnload\"` or add the `defer` attribute",
|
|
2818
|
-
"no-inline-bounce-easing": "Use `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out-expo) for natural deceleration — objects in the real world don't bounce",
|
|
2819
|
-
"no-z-index-9999": "Define a z-index scale in your design tokens (e.g. dropdown: 10, modal: 20, toast: 30). Create a new stacking context with `isolation: isolate` instead of escalating values",
|
|
2820
|
-
"no-inline-exhaustive-style": "Move styles to a CSS class, CSS module, Tailwind utilities, or a styled component — inline objects with many properties hurt readability and create new references every render",
|
|
2821
|
-
"no-side-tab-border": "Use a subtler accent (box-shadow inset, background gradient, or border-bottom) instead of a thick one-sided border",
|
|
2822
|
-
"no-pure-black-background": "Tint the background slightly toward your brand hue — e.g. `#0a0a0f` or Tailwind's `bg-gray-950`. Pure black looks harsh on modern displays",
|
|
2823
|
-
"no-gradient-text": "Use solid text colors for readability. If you need emphasis, use font weight, size, or a distinct color instead of gradients",
|
|
2824
|
-
"no-dark-mode-glow": "Use a subtle `box-shadow` with neutral colors for depth, or `border` with low opacity. Colored glows on dark backgrounds are the default AI-generated aesthetic",
|
|
2825
|
-
"no-justified-text": "Use `text-align: left` for body text, or add `hyphens: auto` and `overflow-wrap: break-word` if you must justify",
|
|
2826
|
-
"no-tiny-text": "Use at least 12px for body content, 16px is ideal. Small text is hard to read, especially on high-DPI mobile screens",
|
|
2827
|
-
"no-wide-letter-spacing": "Reserve wide tracking (letter-spacing > 0.05em) for short uppercase labels, navigation items, and buttons — not body text",
|
|
2828
|
-
"no-gray-on-colored-background": "Use a darker shade of the background color for text, or white/near-white for contrast. Gray text on colored backgrounds looks washed out",
|
|
2829
|
-
"no-layout-transition-inline": "Use `transform` and `opacity` for transitions — they run on the compositor thread. For height animations, use `grid-template-rows: 0fr → 1fr`",
|
|
2830
|
-
"no-disabled-zoom": "Remove `user-scalable=no` and `maximum-scale` from the viewport meta tag. If your layout breaks at 200% zoom, fix the layout — don't punish users with disabilities",
|
|
2831
|
-
"no-outline-none": "Use `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px }` to show focus only for keyboard users while hiding it for mouse clicks",
|
|
2832
|
-
"no-long-transition-duration": "Keep UI transitions under 1s — 100-150ms for instant feedback, 200-300ms for state changes, 300-500ms for layout changes. Use longer durations only for page-load hero animations",
|
|
2833
|
-
"design-no-bold-heading": "Use `font-semibold` (600) or `font-medium` (500) on headings — 700+ crushes letter counter shapes at display sizes",
|
|
2834
|
-
"design-no-redundant-padding-axes": "Collapse `px-N py-N` to `p-N` when both axes match. Keep them split only when one axis varies at a breakpoint (`py-2 md:py-3`)",
|
|
2835
|
-
"design-no-redundant-size-axes": "Collapse `w-N h-N` to `size-N` (Tailwind v3.4+) when both axes match",
|
|
2836
|
-
"design-no-space-on-flex-children": "Use `gap-*` on the flex/grid parent. `space-x-*` / `space-y-*` produce phantom gaps when a sibling is conditionally rendered, lose vertical spacing on wrapped lines, and don't mirror in RTL",
|
|
2837
|
-
"design-no-em-dash-in-jsx-text": "Replace em dashes in JSX text with commas, colons, semicolons, periods, or parentheses — em dashes read as model-output filler",
|
|
2838
|
-
"design-no-three-period-ellipsis": "Use the typographic ellipsis \"…\" (or `…`) instead of three periods — pairs with action-with-followup labels (\"Rename…\", \"Loading…\")",
|
|
2839
|
-
"design-no-default-tailwind-palette": "Replace `indigo-*` / `gray-*` / `slate-*` with project tokens, your brand color, or a less-default neutral (`zinc`, `neutral`, `stone`)",
|
|
2840
|
-
"design-no-vague-button-label": "Name the action: \"Save changes\" instead of \"Continue\", \"Send invite\" instead of \"Submit\", \"Delete account\" instead of \"OK\". The label IS the button's accessible name",
|
|
2841
|
-
"no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
|
|
2842
|
-
"rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
|
|
2843
|
-
"no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
|
|
2844
|
-
"no-uncontrolled-input": "Pass an explicit initial value to `useState` (e.g. `useState(\"\")` instead of `useState()`), add `onChange` (or `readOnly` to opt out) when you supply `value`, and drop `defaultValue` on controlled inputs — React ignores it",
|
|
2845
|
-
"nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
|
|
2846
|
-
"nextjs-async-client-component": "Fetch data in a parent Server Component and pass it as props, or use useQuery/useSWR in the client component",
|
|
2847
|
-
"nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
|
|
2848
|
-
"nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
|
|
2849
|
-
"nextjs-no-client-fetch-for-server-data": "Remove 'use client' and fetch directly in the Server Component — no API round-trip, secrets stay on server",
|
|
2850
|
-
"nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
|
|
2851
|
-
"nextjs-no-client-side-redirect": "Avoid redirects inside useEffect. Use an event handler, middleware, or server-side redirect (App Router: redirect() from next/navigation; Pages Router: getServerSideProps redirect)",
|
|
2852
|
-
"nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
|
|
2853
|
-
"nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
|
|
2854
|
-
"nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
|
|
2855
|
-
"nextjs-inline-script-missing-id": "Add `id=\"descriptive-name\"` so Next.js can track, deduplicate, and re-execute the script correctly",
|
|
2856
|
-
"nextjs-no-font-link": "`import { Inter } from \"next/font/google\"` — self-hosted, zero layout shift, no render-blocking requests",
|
|
2857
|
-
"nextjs-no-css-link": "Import CSS directly: `import './styles.css'` or use CSS Modules: `import styles from './Button.module.css'`",
|
|
2858
|
-
"nextjs-no-polyfill-script": "Next.js includes polyfills for fetch, Promise, Object.assign, Array.from, and 50+ others automatically",
|
|
2859
|
-
"nextjs-no-head-import": "Use the Metadata API instead: `export const metadata = { title: '...' }` or `export async function generateMetadata()`",
|
|
2860
|
-
"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",
|
|
2861
|
-
"server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
|
|
2862
|
-
"server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
|
|
2863
|
-
"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.",
|
|
2864
|
-
"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",
|
|
2865
|
-
"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",
|
|
2866
|
-
"server-dedup-props": "Pass the source array once and derive the projection on the client — passing both doubles RSC serialization bytes",
|
|
2867
|
-
"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.",
|
|
2868
|
-
"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",
|
|
2869
|
-
"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",
|
|
2870
|
-
"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",
|
|
2871
|
-
"query-no-query-in-effect": "React Query manages refetching automatically via queryKey dependencies and the `enabled` option — manual refetch() in useEffect is usually unnecessary",
|
|
2872
|
-
"query-mutation-missing-invalidation": "Add `onSuccess: () => queryClient.invalidateQueries({ queryKey: ['...'] })` so cached data stays in sync after the mutation",
|
|
2873
|
-
"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",
|
|
2874
|
-
"js-flatmap-filter": "Use `.flatMap(item => condition ? [value] : [])` — transforms and filters in a single pass instead of creating an intermediate array",
|
|
2875
|
-
"js-hoist-intl": "Hoist `new Intl.NumberFormat(...)` to module scope or wrap in `useMemo` — Intl constructors allocate dozens of objects per locale lookup",
|
|
2876
|
-
"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`",
|
|
2877
|
-
"js-length-check-first": "Short-circuit with `a.length === b.length && a.every((x, i) => x === b[i])` — unequal-length arrays exit immediately",
|
|
2878
|
-
"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",
|
|
2879
|
-
"js-tosorted-immutable": "Use `array.toSorted()` (ES2023) instead of `[...array].sort()` for immutable sorting without the spread allocation",
|
|
2880
|
-
"js-hoist-regexp": "Hoist `new RegExp(...)` (or large regex literals) to a module-level constant so it isn't recompiled on every loop iteration",
|
|
2881
|
-
"js-min-max-loop": "Use `Math.min(...array)` / `Math.max(...array)` instead of sorting just to read the first or last element",
|
|
2882
|
-
"js-set-map-lookups": "Use a `Set` or `Map` for repeated membership tests / keyed lookups — `Array.includes`/`find` is O(n) per call",
|
|
2883
|
-
"js-batch-dom-css": "Batch DOM/CSS reads and writes — interleaving them inside a loop causes layout thrashing. Read first, then write",
|
|
2884
|
-
"js-index-maps": "Build an index `Map` once outside the loop instead of `array.find(...)` inside it",
|
|
2885
|
-
"js-cache-storage": "Cache repeated `localStorage`/`sessionStorage` reads in a local variable — each access serializes/deserializes",
|
|
2886
|
-
"js-early-exit": "Add an early `return` / `continue` to flatten deep nesting and short-circuit when the predicate is already known",
|
|
2887
|
-
"no-eval": "Use `JSON.parse` for serialized data, `Function(...)` (still careful) for trusted templates, or refactor to avoid dynamic code execution",
|
|
2888
|
-
"async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently",
|
|
2889
|
-
"rn-no-raw-text": "Wrap text in a `<Text>` component: `<Text>{value}</Text>` — raw strings outside `<Text>` crash on React Native",
|
|
2890
|
-
"rn-no-deprecated-modules": "Import from the community package instead — deprecated modules were removed from the react-native core",
|
|
2891
|
-
"rn-no-legacy-expo-packages": "Migrate to the recommended replacement package — legacy Expo packages are no longer maintained",
|
|
2892
|
-
"rn-no-dimensions-get": "Use `const { width, height } = useWindowDimensions()` — it updates reactively on rotation and resize",
|
|
2893
|
-
"rn-no-inline-flatlist-renderitem": "Extract renderItem to a named function or wrap in useCallback to avoid re-creating on every render",
|
|
2894
|
-
"rn-no-legacy-shadow-styles": "Use `boxShadow` for cross-platform shadows on the new architecture instead of platform-specific shadow properties",
|
|
2895
|
-
"rn-prefer-reanimated": "Use `import Animated from 'react-native-reanimated'` — animations run on the UI thread instead of the JS thread",
|
|
2896
|
-
"rn-no-single-element-style-array": "Use `style={value}` instead of `style={[value]}` — single-element arrays add unnecessary allocation",
|
|
2897
|
-
"rn-prefer-pressable": "Use `<Pressable>` from react-native (or react-native-gesture-handler) instead of legacy Touchable* components",
|
|
2898
|
-
"rn-prefer-expo-image": "Use `<Image>` from `expo-image` instead of `react-native` — same prop API, plus disk + memory caching, placeholders, and crossfades",
|
|
2899
|
-
"rn-no-non-native-navigator": "Use `@react-navigation/native-stack` (or `native-tabs` in v7+) for platform-native transitions and gestures",
|
|
2900
|
-
"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",
|
|
2901
|
-
"rn-no-scrollview-mapped-list": "Use FlashList, LegendList, or FlatList — `<ScrollView>{items.map(...)}</ScrollView>` mounts every row in memory",
|
|
2902
|
-
"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",
|
|
2903
|
-
"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",
|
|
2904
|
-
"rn-prefer-content-inset-adjustment": "Drop the SafeAreaView wrapper and set `contentInsetAdjustmentBehavior=\"automatic\"` on the ScrollView for native safe-area handling",
|
|
2905
|
-
"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",
|
|
2906
|
-
"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",
|
|
2907
|
-
"rn-animation-reaction-as-derived": "Replace useAnimatedReaction with `useDerivedValue(() => ..., [deps])` — shorter, native dependency tracking, no side-effect implication",
|
|
2908
|
-
"rn-bottom-sheet-prefer-native": "Use `<Modal presentationStyle=\"formSheet\">` (RN v7+) for native gesture handling and snap points",
|
|
2909
|
-
"rn-scrollview-dynamic-padding": "Use `contentInset={{ bottom: dynamicValue }}` — the OS applies it as an offset without reflowing the scroll content",
|
|
2910
|
-
"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",
|
|
2911
|
-
"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",
|
|
2912
|
-
"tanstack-start-server-fn-validate-input": "Add `.inputValidator(schema)` before `.handler()` — data crosses a network boundary and must be validated at runtime",
|
|
2913
|
-
"tanstack-start-no-useeffect-fetch": "Fetch data in the route `loader` instead — the router coordinates loading before rendering to avoid waterfalls",
|
|
2914
|
-
"tanstack-start-missing-head-content": "Add `<HeadContent />` inside `<head>` in your __root route — without it, route `head()` meta tags are silently dropped",
|
|
2915
|
-
"tanstack-start-no-anchor-element": "`import { Link } from '@tanstack/react-router'` — enables type-safe routes, preloading via `preload=\"intent\"`, and client-side navigation",
|
|
2916
|
-
"tanstack-start-server-fn-method-order": "Chain methods in order: .middleware() → .inputValidator() → .client() → .server() → .handler() — types depend on this sequence",
|
|
2917
|
-
"tanstack-start-no-navigate-in-render": "Use `throw redirect({ to: '/path' })` in `beforeLoad` or `loader` instead — navigate() during render causes hydration issues",
|
|
2918
|
-
"tanstack-start-no-dynamic-server-fn-import": "Use `import { myFn } from '~/utils/my.functions'` — the bundler replaces server code with RPC stubs only for static imports",
|
|
2919
|
-
"tanstack-start-no-use-server-in-handler": "TanStack Start handles server boundaries automatically via the Vite plugin — \"use server\" inside createServerFn causes compilation errors",
|
|
2920
|
-
"tanstack-start-no-secrets-in-loader": "Loaders are isomorphic (run on both server and client). Wrap secret access in `createServerFn()` so it stays server-only",
|
|
2921
|
-
"tanstack-start-get-mutation": "Use `createServerFn({ method: 'POST' })` for data modifications — GET requests can be triggered by prefetching and are vulnerable to CSRF",
|
|
2922
|
-
"tanstack-start-redirect-in-try-catch": "TanStack Router's `redirect()` and `notFound()` throw special errors caught by the router. Move them outside the try block or re-throw in the catch",
|
|
2923
|
-
"tanstack-start-loader-parallel-fetch": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to avoid request waterfalls in route loaders"
|
|
2924
|
-
};
|
|
2925
|
-
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
2926
|
-
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
2927
|
-
const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
2928
|
-
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
2929
|
-
if (plugin === "react-hooks-js") return {
|
|
2930
|
-
message: REACT_COMPILER_MESSAGE,
|
|
2931
|
-
help: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help
|
|
2932
|
-
};
|
|
2933
|
-
return {
|
|
2934
|
-
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
2935
|
-
help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
|
|
2936
|
-
};
|
|
2937
|
-
};
|
|
2938
|
-
const parseRuleCode = (code) => {
|
|
2939
|
-
const match = code.match(/^(.+)\((.+)\)$/);
|
|
2940
|
-
if (!match) return {
|
|
2941
|
-
plugin: "unknown",
|
|
2942
|
-
rule: code
|
|
2943
|
-
};
|
|
2944
|
-
return {
|
|
2945
|
-
plugin: match[1].replace(/^eslint-plugin-/, ""),
|
|
2946
|
-
rule: match[2]
|
|
2947
|
-
};
|
|
2948
|
-
};
|
|
2949
|
-
const resolveOxlintBinary = () => {
|
|
2950
|
-
const oxlintMainPath = esmRequire.resolve("oxlint");
|
|
2951
|
-
const oxlintPackageDirectory = path.resolve(path.dirname(oxlintMainPath), "..");
|
|
2952
|
-
return path.join(oxlintPackageDirectory, "bin", "oxlint");
|
|
2953
|
-
};
|
|
2954
|
-
const resolvePluginPath = () => {
|
|
2955
|
-
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
2956
|
-
const pluginPath = path.join(currentDirectory, "react-doctor-plugin.js");
|
|
2957
|
-
if (fs.existsSync(pluginPath)) return pluginPath;
|
|
2958
|
-
const distPluginPath = path.resolve(currentDirectory, "../../dist/react-doctor-plugin.js");
|
|
2959
|
-
if (fs.existsSync(distPluginPath)) return distPluginPath;
|
|
2960
|
-
return pluginPath;
|
|
2961
|
-
};
|
|
2962
|
-
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
2963
|
-
return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
|
|
2964
|
-
};
|
|
2965
|
-
const SANITIZED_ENV = (() => {
|
|
2966
|
-
const sanitized = {};
|
|
2967
|
-
for (const [name, value] of Object.entries(process.env)) {
|
|
2968
|
-
if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
|
|
2969
|
-
if (name.startsWith("npm_config_")) continue;
|
|
2970
|
-
sanitized[name] = value;
|
|
2971
|
-
}
|
|
2972
|
-
return sanitized;
|
|
2973
|
-
})();
|
|
2974
|
-
const OXLINT_SPAWN_TIMEOUT_MS = 5 * 6e4;
|
|
2975
|
-
const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
|
|
2976
|
-
const child = spawn(nodeBinaryPath, args, {
|
|
2977
|
-
cwd: rootDirectory,
|
|
2978
|
-
env: SANITIZED_ENV
|
|
2979
|
-
});
|
|
2980
|
-
const timeoutHandle = setTimeout(() => {
|
|
2981
|
-
child.kill("SIGKILL");
|
|
2982
|
-
reject(/* @__PURE__ */ new Error(`oxlint did not return within ${OXLINT_SPAWN_TIMEOUT_MS / 1e3}s — please report`));
|
|
2983
|
-
}, OXLINT_SPAWN_TIMEOUT_MS);
|
|
2984
|
-
timeoutHandle.unref?.();
|
|
2985
|
-
const stdoutBuffers = [];
|
|
2986
|
-
const stderrBuffers = [];
|
|
2987
|
-
let stdoutByteCount = 0;
|
|
2988
|
-
let stderrByteCount = 0;
|
|
2989
|
-
let didKillForSize = false;
|
|
2990
|
-
const killIfTooLarge = (incomingBytes, isStdout) => {
|
|
2991
|
-
if (isStdout) stdoutByteCount += incomingBytes;
|
|
2992
|
-
else stderrByteCount += incomingBytes;
|
|
2993
|
-
if (stdoutByteCount + stderrByteCount > 52428800 && !didKillForSize) {
|
|
2994
|
-
didKillForSize = true;
|
|
2995
|
-
child.kill("SIGKILL");
|
|
2996
|
-
return true;
|
|
2997
|
-
}
|
|
2998
|
-
return false;
|
|
2999
|
-
};
|
|
3000
|
-
child.stdout.on("data", (buffer) => {
|
|
3001
|
-
if (didKillForSize) return;
|
|
3002
|
-
stdoutBuffers.push(buffer);
|
|
3003
|
-
killIfTooLarge(buffer.length, true);
|
|
3004
|
-
});
|
|
3005
|
-
child.stderr.on("data", (buffer) => {
|
|
3006
|
-
if (didKillForSize) return;
|
|
3007
|
-
stderrBuffers.push(buffer);
|
|
3008
|
-
killIfTooLarge(buffer.length, false);
|
|
3009
|
-
});
|
|
3010
|
-
child.on("error", (error) => {
|
|
3011
|
-
clearTimeout(timeoutHandle);
|
|
3012
|
-
reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`));
|
|
3013
|
-
});
|
|
3014
|
-
child.on("close", (_code, signal) => {
|
|
3015
|
-
clearTimeout(timeoutHandle);
|
|
3016
|
-
if (didKillForSize) {
|
|
3017
|
-
reject(/* @__PURE__ */ new Error(`oxlint output exceeded ${PROXY_OUTPUT_MAX_BYTES} bytes — scan a smaller subset with --diff or --staged`));
|
|
3018
|
-
return;
|
|
3019
|
-
}
|
|
3020
|
-
if (signal) {
|
|
3021
|
-
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
3022
|
-
const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
|
|
3023
|
-
const detail = stderrOutput ? `: ${stderrOutput}` : "";
|
|
3024
|
-
reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
|
|
3025
|
-
return;
|
|
3026
|
-
}
|
|
3027
|
-
const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
|
|
3028
|
-
if (!output) {
|
|
3029
|
-
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
3030
|
-
if (stderrOutput) {
|
|
3031
|
-
reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
|
|
3032
|
-
return;
|
|
3033
|
-
}
|
|
3034
|
-
}
|
|
3035
|
-
resolve(output);
|
|
3036
|
-
});
|
|
3037
|
-
});
|
|
3038
|
-
const isOxlintOutput = (value) => {
|
|
3039
|
-
if (typeof value !== "object" || value === null) return false;
|
|
3040
|
-
const candidate = value;
|
|
3041
|
-
return Array.isArray(candidate.diagnostics);
|
|
3042
|
-
};
|
|
3043
|
-
const parseOxlintOutput = (stdout) => {
|
|
3044
|
-
if (!stdout) return [];
|
|
3045
|
-
const jsonStart = stdout.indexOf("{");
|
|
3046
|
-
const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
|
|
3047
|
-
let parsed;
|
|
3048
|
-
try {
|
|
3049
|
-
parsed = JSON.parse(sanitizedStdout);
|
|
3050
|
-
} catch {
|
|
3051
|
-
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
|
|
3052
|
-
}
|
|
3053
|
-
if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
|
|
3054
|
-
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
3055
|
-
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
3056
|
-
const primaryLabel = diagnostic.labels[0];
|
|
3057
|
-
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
3058
|
-
return {
|
|
3059
|
-
filePath: diagnostic.filename,
|
|
3060
|
-
plugin,
|
|
3061
|
-
rule,
|
|
3062
|
-
severity: diagnostic.severity,
|
|
3063
|
-
message: cleaned.message,
|
|
3064
|
-
help: cleaned.help,
|
|
3065
|
-
url: diagnostic.url,
|
|
3066
|
-
line: primaryLabel?.span.line ?? 0,
|
|
3067
|
-
column: primaryLabel?.span.column ?? 0,
|
|
3068
|
-
category: resolveDiagnosticCategory(plugin, rule)
|
|
3069
|
-
};
|
|
3070
|
-
});
|
|
3071
|
-
};
|
|
3072
|
-
const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
|
|
3073
|
-
const resolveTsConfigRelativePath = (rootDirectory) => {
|
|
3074
|
-
for (const filename of TSCONFIG_FILENAMES) if (fs.existsSync(path.join(rootDirectory, filename))) return `./${filename}`;
|
|
3075
|
-
return null;
|
|
3076
|
-
};
|
|
3077
|
-
let didValidateRuleRegistration = false;
|
|
3078
|
-
const validateRuleRegistration = () => {
|
|
3079
|
-
if (didValidateRuleRegistration) return;
|
|
3080
|
-
didValidateRuleRegistration = true;
|
|
3081
|
-
const missingHelp = [];
|
|
3082
|
-
const missingCategory = [];
|
|
3083
|
-
for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
|
|
3084
|
-
const ruleName = fullKey.replace(/^react-doctor\//, "");
|
|
3085
|
-
if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
|
|
3086
|
-
if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
|
|
3087
|
-
}
|
|
3088
|
-
if (missingCategory.length > 0 || missingHelp.length > 0) {
|
|
3089
|
-
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("; ");
|
|
3090
|
-
console.warn(`[react-doctor] rule-registration drift: ${detail}`);
|
|
3091
|
-
}
|
|
3092
|
-
};
|
|
3093
|
-
const runOxlint = async (options) => {
|
|
3094
|
-
const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, reactMajorVersion = null, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true } = options;
|
|
3095
|
-
validateRuleRegistration();
|
|
3096
|
-
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
3097
|
-
const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
|
|
3098
|
-
const configPath = path.join(configDirectory, "oxlintrc.json");
|
|
3099
|
-
const pluginPath = resolvePluginPath();
|
|
3100
|
-
const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
|
|
3101
|
-
const config = createOxlintConfig({
|
|
3102
|
-
pluginPath,
|
|
3103
|
-
framework,
|
|
3104
|
-
hasReactCompiler,
|
|
3105
|
-
hasTanStackQuery,
|
|
3106
|
-
customRulesOnly,
|
|
3107
|
-
reactMajorVersion,
|
|
3108
|
-
extendsPaths
|
|
3109
|
-
});
|
|
3110
|
-
const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
|
|
3111
|
-
try {
|
|
3112
|
-
const baseArgs = [
|
|
3113
|
-
resolveOxlintBinary(),
|
|
3114
|
-
"-c",
|
|
3115
|
-
configPath,
|
|
3116
|
-
"--format",
|
|
3117
|
-
"json"
|
|
3118
|
-
];
|
|
3119
|
-
if (hasTypeScript) {
|
|
3120
|
-
const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
|
|
3121
|
-
if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
|
|
3122
|
-
}
|
|
3123
|
-
const combinedPatterns = collectIgnorePatterns(rootDirectory);
|
|
3124
|
-
if (combinedPatterns.length > 0) {
|
|
3125
|
-
const combinedIgnorePath = path.join(configDirectory, "combined.ignore");
|
|
3126
|
-
fs.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
|
|
3127
|
-
baseArgs.push("--ignore-path", combinedIgnorePath);
|
|
3128
|
-
}
|
|
3129
|
-
const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
|
|
3130
|
-
const writeOxlintConfig = (configToWrite) => {
|
|
3131
|
-
fs.rmSync(configPath, { force: true });
|
|
3132
|
-
const fileHandle = fs.openSync(configPath, "wx", 384);
|
|
3133
|
-
try {
|
|
3134
|
-
fs.writeFileSync(fileHandle, JSON.stringify(configToWrite));
|
|
3135
|
-
} finally {
|
|
3136
|
-
fs.closeSync(fileHandle);
|
|
3137
|
-
}
|
|
3138
|
-
};
|
|
3139
|
-
const spawnLintBatches = async () => {
|
|
3140
|
-
const allDiagnostics = [];
|
|
3141
|
-
for (const batch of fileBatches) {
|
|
3142
|
-
const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
|
|
3143
|
-
allDiagnostics.push(...parseOxlintOutput(stdout));
|
|
3144
|
-
}
|
|
3145
|
-
return allDiagnostics;
|
|
3146
|
-
};
|
|
3147
|
-
writeOxlintConfig(config);
|
|
3148
|
-
try {
|
|
3149
|
-
return await spawnLintBatches();
|
|
3150
|
-
} catch (error) {
|
|
3151
|
-
if (extendsPaths.length === 0) throw error;
|
|
3152
|
-
writeOxlintConfig(createOxlintConfig({
|
|
3153
|
-
pluginPath,
|
|
3154
|
-
framework,
|
|
3155
|
-
hasReactCompiler,
|
|
3156
|
-
hasTanStackQuery,
|
|
3157
|
-
customRulesOnly,
|
|
3158
|
-
reactMajorVersion,
|
|
3159
|
-
extendsPaths: []
|
|
3160
|
-
}));
|
|
3161
|
-
return await spawnLintBatches();
|
|
3162
|
-
}
|
|
3163
|
-
} finally {
|
|
3164
|
-
restoreDisableDirectives();
|
|
3165
|
-
fs.rmSync(configDirectory, {
|
|
3166
|
-
recursive: true,
|
|
3167
|
-
force: true
|
|
3168
|
-
});
|
|
3169
|
-
}
|
|
3170
|
-
};
|
|
3171
|
-
//#endregion
|
|
3172
|
-
//#region src/utils/get-diff-files.ts
|
|
3173
|
-
const runGit = (cwd, args) => {
|
|
3174
|
-
const result = spawnSync("git", args, {
|
|
3175
|
-
cwd,
|
|
3176
|
-
stdio: [
|
|
3177
|
-
"ignore",
|
|
3178
|
-
"pipe",
|
|
3179
|
-
"pipe"
|
|
3180
|
-
],
|
|
3181
|
-
encoding: "utf-8"
|
|
3182
|
-
});
|
|
3183
|
-
if (result.error || result.status !== 0) return null;
|
|
3184
|
-
return result.stdout.toString().trim();
|
|
3185
|
-
};
|
|
3186
|
-
const getCurrentBranch = (directory) => {
|
|
3187
|
-
const branch = runGit(directory, [
|
|
3188
|
-
"rev-parse",
|
|
3189
|
-
"--abbrev-ref",
|
|
3190
|
-
"HEAD"
|
|
3191
|
-
]);
|
|
3192
|
-
if (!branch) return null;
|
|
3193
|
-
return branch === "HEAD" ? null : branch;
|
|
3194
|
-
};
|
|
3195
|
-
const detectDefaultBranch = (directory) => {
|
|
3196
|
-
const reference = runGit(directory, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
3197
|
-
if (reference) return reference.replace("refs/remotes/origin/", "");
|
|
3198
|
-
const output = runGit(directory, [
|
|
3199
|
-
"for-each-ref",
|
|
3200
|
-
"--format=%(refname:short)",
|
|
3201
|
-
...DEFAULT_BRANCH_CANDIDATES.map((candidate) => `refs/heads/${candidate}`)
|
|
3202
|
-
]);
|
|
3203
|
-
if (output) {
|
|
3204
|
-
const firstLine = output.split("\n")[0]?.trim();
|
|
3205
|
-
if (firstLine) return firstLine;
|
|
3206
|
-
}
|
|
3207
|
-
return null;
|
|
3208
|
-
};
|
|
3209
|
-
const branchExists = (directory, branch) => {
|
|
3210
|
-
const result = spawnSync("git", [
|
|
3211
|
-
"rev-parse",
|
|
3212
|
-
"--verify",
|
|
3213
|
-
branch
|
|
3214
|
-
], {
|
|
3215
|
-
cwd: directory,
|
|
3216
|
-
stdio: [
|
|
3217
|
-
"ignore",
|
|
3218
|
-
"pipe",
|
|
3219
|
-
"pipe"
|
|
3220
|
-
]
|
|
3221
|
-
});
|
|
3222
|
-
return !result.error && result.status === 0;
|
|
3223
|
-
};
|
|
3224
|
-
const runGitNullSeparated = (cwd, args) => {
|
|
3225
|
-
const result = spawnSync("git", args, {
|
|
3226
|
-
cwd,
|
|
3227
|
-
stdio: [
|
|
3228
|
-
"ignore",
|
|
3229
|
-
"pipe",
|
|
3230
|
-
"pipe"
|
|
3231
|
-
],
|
|
3232
|
-
encoding: "utf-8"
|
|
3233
|
-
});
|
|
3234
|
-
if (result.error || result.status !== 0) return null;
|
|
3235
|
-
return result.stdout.toString().split("\0").filter((filePath) => filePath.length > 0);
|
|
3236
|
-
};
|
|
3237
|
-
const getChangedFilesSinceBranch = (directory, baseBranch) => {
|
|
3238
|
-
const mergeBase = runGit(directory, [
|
|
3239
|
-
"merge-base",
|
|
3240
|
-
baseBranch,
|
|
3241
|
-
"HEAD"
|
|
3242
|
-
]);
|
|
3243
|
-
if (mergeBase === null) return null;
|
|
3244
|
-
return runGitNullSeparated(directory, [
|
|
3245
|
-
"diff",
|
|
3246
|
-
"-z",
|
|
3247
|
-
"--name-only",
|
|
3248
|
-
"--diff-filter=ACMR",
|
|
3249
|
-
"--relative",
|
|
3250
|
-
mergeBase
|
|
3251
|
-
]);
|
|
3252
|
-
};
|
|
3253
|
-
const getUncommittedChangedFiles = (directory) => {
|
|
3254
|
-
return runGitNullSeparated(directory, [
|
|
3255
|
-
"diff",
|
|
3256
|
-
"-z",
|
|
3257
|
-
"--name-only",
|
|
3258
|
-
"--diff-filter=ACMR",
|
|
3259
|
-
"--relative",
|
|
3260
|
-
"HEAD"
|
|
3261
|
-
]) ?? [];
|
|
3262
|
-
};
|
|
3263
|
-
const getDiffInfo = (directory, explicitBaseBranch) => {
|
|
3264
|
-
if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) throw new Error("Diff base branch cannot be empty.");
|
|
3265
|
-
const currentBranch = getCurrentBranch(directory);
|
|
3266
|
-
if (!currentBranch) return null;
|
|
3267
|
-
const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
|
|
3268
|
-
if (!baseBranch) return null;
|
|
3269
|
-
if (explicitBaseBranch && !branchExists(directory, explicitBaseBranch)) throw new Error(`Diff base branch "${explicitBaseBranch}" does not exist (run \`git fetch\` to update remote refs).`);
|
|
3270
|
-
if (currentBranch === baseBranch) {
|
|
3271
|
-
const uncommittedFiles = getUncommittedChangedFiles(directory);
|
|
3272
|
-
if (uncommittedFiles.length === 0) return null;
|
|
3273
|
-
return {
|
|
3274
|
-
currentBranch,
|
|
3275
|
-
baseBranch,
|
|
3276
|
-
changedFiles: uncommittedFiles,
|
|
3277
|
-
isCurrentChanges: true
|
|
3278
|
-
};
|
|
3279
|
-
}
|
|
3280
|
-
const changedFiles = getChangedFilesSinceBranch(directory, baseBranch);
|
|
3281
|
-
if (changedFiles === null) return null;
|
|
3282
|
-
return {
|
|
3283
|
-
currentBranch,
|
|
3284
|
-
baseBranch,
|
|
3285
|
-
changedFiles
|
|
3286
|
-
};
|
|
3287
|
-
};
|
|
3288
|
-
const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
|
|
3289
|
-
//#endregion
|
|
3290
|
-
//#region src/index.ts
|
|
3291
|
-
const clearCaches = () => {
|
|
3292
|
-
clearProjectCache();
|
|
3293
|
-
clearConfigCache();
|
|
3294
|
-
clearPackageJsonCache();
|
|
3295
|
-
clearIgnorePatternsCache();
|
|
3296
|
-
};
|
|
3297
|
-
const toJsonReport = (result, options) => buildJsonReport({
|
|
3298
|
-
version: options.version,
|
|
3299
|
-
directory: options.directory ?? result.project.rootDirectory,
|
|
3300
|
-
mode: options.mode ?? "full",
|
|
3301
|
-
diff: null,
|
|
3302
|
-
scans: [{
|
|
3303
|
-
directory: result.project.rootDirectory,
|
|
3304
|
-
result: {
|
|
3305
|
-
diagnostics: result.diagnostics,
|
|
3306
|
-
score: result.score,
|
|
3307
|
-
skippedChecks: [],
|
|
3308
|
-
project: result.project,
|
|
3309
|
-
elapsedMilliseconds: result.elapsedMilliseconds
|
|
3310
|
-
}
|
|
3311
|
-
}],
|
|
3312
|
-
totalElapsedMilliseconds: result.elapsedMilliseconds
|
|
3313
|
-
});
|
|
3314
|
-
const EMPTY_DIAGNOSTICS = [];
|
|
3315
|
-
const settledOrEmpty = (settled, label) => {
|
|
3316
|
-
if (settled.status === "fulfilled") return settled.value;
|
|
3317
|
-
console.error(`${label} rejected:`, settled.reason);
|
|
3318
|
-
return EMPTY_DIAGNOSTICS;
|
|
3319
|
-
};
|
|
3320
|
-
const diagnose = async (directory, options = {}) => {
|
|
3321
|
-
const startTime = globalThis.performance.now();
|
|
3322
|
-
const requestedDirectory = path.resolve(directory);
|
|
3323
|
-
const initialLoadedConfig = loadConfigWithSource(requestedDirectory);
|
|
3324
|
-
const directoryAfterRedirect = resolveConfigRootDir(initialLoadedConfig?.config ?? null, initialLoadedConfig?.sourceDirectory ?? null) ?? requestedDirectory;
|
|
3325
|
-
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect);
|
|
3326
|
-
if (!resolvedDirectory) throw new ProjectNotFoundError(directoryAfterRedirect);
|
|
3327
|
-
const userConfig = initialLoadedConfig?.config ?? loadConfigWithSource(resolvedDirectory)?.config ?? null;
|
|
3328
|
-
const includePaths = options.includePaths ?? [];
|
|
3329
|
-
const isDiffMode = includePaths.length > 0;
|
|
3330
|
-
const projectInfo = discoverProject(resolvedDirectory);
|
|
3331
|
-
if (!projectInfo.reactVersion) throw new NoReactDependencyError(resolvedDirectory);
|
|
3332
|
-
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
|
|
3333
|
-
const readFileLinesSync = createNodeReadFileLinesSync(resolvedDirectory);
|
|
3334
|
-
const effectiveLint = options.lint ?? userConfig?.lint ?? true;
|
|
3335
|
-
const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
|
|
3336
|
-
const effectiveRespectInlineDisables = options.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true;
|
|
3337
|
-
const lintPromise = effectiveLint ? runOxlint({
|
|
3338
|
-
rootDirectory: resolvedDirectory,
|
|
3339
|
-
hasTypeScript: projectInfo.hasTypeScript,
|
|
3340
|
-
framework: projectInfo.framework,
|
|
3341
|
-
hasReactCompiler: projectInfo.hasReactCompiler,
|
|
3342
|
-
hasTanStackQuery: projectInfo.hasTanStackQuery,
|
|
3343
|
-
reactMajorVersion: parseReactMajor(projectInfo.reactVersion),
|
|
3344
|
-
includePaths: lintIncludePaths,
|
|
3345
|
-
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
3346
|
-
respectInlineDisables: effectiveRespectInlineDisables,
|
|
3347
|
-
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true
|
|
3348
|
-
}).catch((error) => {
|
|
3349
|
-
console.error("Lint failed:", error);
|
|
3350
|
-
return EMPTY_DIAGNOSTICS;
|
|
3351
|
-
}) : Promise.resolve(EMPTY_DIAGNOSTICS);
|
|
3352
|
-
const deadCodePromise = effectiveDeadCode && !isDiffMode ? runKnip(resolvedDirectory).catch((error) => {
|
|
3353
|
-
console.error("Dead code analysis failed:", error);
|
|
3354
|
-
return EMPTY_DIAGNOSTICS;
|
|
3355
|
-
}) : Promise.resolve(EMPTY_DIAGNOSTICS);
|
|
3356
|
-
const [lintSettled, deadCodeSettled] = await Promise.allSettled([lintPromise, deadCodePromise]);
|
|
3357
|
-
const lintDiagnostics = settledOrEmpty(lintSettled, "Lint");
|
|
3358
|
-
const deadCodeDiagnostics = settledOrEmpty(deadCodeSettled, "Dead code");
|
|
3359
|
-
const environmentDiagnostics = isDiffMode ? [] : checkReducedMotion(resolvedDirectory);
|
|
3360
|
-
const diagnostics = mergeAndFilterDiagnostics([
|
|
3361
|
-
...lintDiagnostics,
|
|
3362
|
-
...deadCodeDiagnostics,
|
|
3363
|
-
...environmentDiagnostics
|
|
3364
|
-
], resolvedDirectory, userConfig, readFileLinesSync, { respectInlineDisables: effectiveRespectInlineDisables });
|
|
3365
|
-
const elapsedMilliseconds = globalThis.performance.now() - startTime;
|
|
3366
|
-
return {
|
|
3367
|
-
diagnostics,
|
|
3368
|
-
score: await calculateScore(diagnostics),
|
|
3369
|
-
project: projectInfo,
|
|
3370
|
-
elapsedMilliseconds
|
|
3371
|
-
};
|
|
3372
|
-
};
|
|
3373
|
-
//#endregion
|
|
3374
|
-
export { AmbiguousProjectError, NoReactDependencyError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isReactDoctorError, summarizeDiagnostics, toJsonReport };
|
|
3375
|
-
|
|
3376
|
-
//# sourceMappingURL=index.js.map
|