react-doctor 0.0.13 → 0.0.15
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 +40 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +771 -0
- package/dist/index.js.map +1 -0
- package/dist/react-doctor-plugin.d.ts.map +1 -1
- package/dist/react-doctor-plugin.js +206 -1
- package/dist/react-doctor-plugin.js.map +1 -1
- package/package.json +24 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { main } from "knip";
|
|
7
|
+
import { createOptions } from "knip/session";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
//#region src/constants.ts
|
|
12
|
+
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
13
|
+
const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
|
|
14
|
+
const ERROR_PREVIEW_LENGTH_CHARS = 200;
|
|
15
|
+
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
16
|
+
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/utils/calculate-score.ts
|
|
20
|
+
const calculateScore = async (diagnostics) => {
|
|
21
|
+
const payload = diagnostics.map((diagnostic) => ({
|
|
22
|
+
plugin: diagnostic.plugin,
|
|
23
|
+
rule: diagnostic.rule,
|
|
24
|
+
severity: diagnostic.severity
|
|
25
|
+
}));
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(SCORE_API_URL, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({ diagnostics: payload })
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) return null;
|
|
33
|
+
return await response.json();
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/plugin/constants.ts
|
|
41
|
+
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/utils/read-package-json.ts
|
|
45
|
+
const readPackageJson = (packageJsonPath) => JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/utils/check-reduced-motion.ts
|
|
49
|
+
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
|
|
50
|
+
const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
|
|
51
|
+
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
52
|
+
filePath: "package.json",
|
|
53
|
+
plugin: "react-doctor",
|
|
54
|
+
rule: "require-reduced-motion",
|
|
55
|
+
severity: "error",
|
|
56
|
+
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
57
|
+
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
58
|
+
line: 0,
|
|
59
|
+
column: 0,
|
|
60
|
+
category: "Accessibility",
|
|
61
|
+
weight: 2
|
|
62
|
+
};
|
|
63
|
+
const checkReducedMotion = (rootDirectory) => {
|
|
64
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
65
|
+
if (!fs.existsSync(packageJsonPath)) return [];
|
|
66
|
+
let hasMotionLibrary = false;
|
|
67
|
+
try {
|
|
68
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
69
|
+
const allDependencies = {
|
|
70
|
+
...packageJson.dependencies,
|
|
71
|
+
...packageJson.devDependencies
|
|
72
|
+
};
|
|
73
|
+
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
if (!hasMotionLibrary) return [];
|
|
78
|
+
try {
|
|
79
|
+
execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
|
|
80
|
+
cwd: rootDirectory,
|
|
81
|
+
stdio: "pipe"
|
|
82
|
+
});
|
|
83
|
+
return [];
|
|
84
|
+
} catch {
|
|
85
|
+
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/utils/discover-project.ts
|
|
91
|
+
const REACT_COMPILER_PACKAGES = new Set([
|
|
92
|
+
"babel-plugin-react-compiler",
|
|
93
|
+
"react-compiler-runtime",
|
|
94
|
+
"eslint-plugin-react-compiler"
|
|
95
|
+
]);
|
|
96
|
+
const NEXT_CONFIG_FILENAMES = [
|
|
97
|
+
"next.config.js",
|
|
98
|
+
"next.config.mjs",
|
|
99
|
+
"next.config.ts",
|
|
100
|
+
"next.config.cjs"
|
|
101
|
+
];
|
|
102
|
+
const BABEL_CONFIG_FILENAMES = [
|
|
103
|
+
".babelrc",
|
|
104
|
+
".babelrc.json",
|
|
105
|
+
"babel.config.js",
|
|
106
|
+
"babel.config.json",
|
|
107
|
+
"babel.config.cjs",
|
|
108
|
+
"babel.config.mjs"
|
|
109
|
+
];
|
|
110
|
+
const VITE_CONFIG_FILENAMES = [
|
|
111
|
+
"vite.config.js",
|
|
112
|
+
"vite.config.ts",
|
|
113
|
+
"vite.config.mjs",
|
|
114
|
+
"vite.config.cjs"
|
|
115
|
+
];
|
|
116
|
+
const REACT_COMPILER_CONFIG_PATTERN = /react-compiler|reactCompiler/;
|
|
117
|
+
const FRAMEWORK_PACKAGES = {
|
|
118
|
+
next: "nextjs",
|
|
119
|
+
vite: "vite",
|
|
120
|
+
"react-scripts": "cra",
|
|
121
|
+
"@remix-run/react": "remix",
|
|
122
|
+
gatsby: "gatsby"
|
|
123
|
+
};
|
|
124
|
+
const countSourceFiles = (rootDirectory) => {
|
|
125
|
+
const result = spawnSync("git", [
|
|
126
|
+
"ls-files",
|
|
127
|
+
"--cached",
|
|
128
|
+
"--others",
|
|
129
|
+
"--exclude-standard"
|
|
130
|
+
], {
|
|
131
|
+
cwd: rootDirectory,
|
|
132
|
+
encoding: "utf-8",
|
|
133
|
+
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
134
|
+
});
|
|
135
|
+
if (result.error || result.status !== 0) return 0;
|
|
136
|
+
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
|
|
137
|
+
};
|
|
138
|
+
const collectAllDependencies = (packageJson) => ({
|
|
139
|
+
...packageJson.dependencies,
|
|
140
|
+
...packageJson.devDependencies
|
|
141
|
+
});
|
|
142
|
+
const detectFramework = (dependencies) => {
|
|
143
|
+
for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
|
|
144
|
+
return "unknown";
|
|
145
|
+
};
|
|
146
|
+
const extractDependencyInfo = (packageJson) => {
|
|
147
|
+
const allDependencies = collectAllDependencies(packageJson);
|
|
148
|
+
return {
|
|
149
|
+
reactVersion: allDependencies.react ?? null,
|
|
150
|
+
framework: detectFramework(allDependencies)
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
const parsePnpmWorkspacePatterns = (rootDirectory) => {
|
|
154
|
+
const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
|
|
155
|
+
if (!fs.existsSync(workspacePath)) return [];
|
|
156
|
+
const content = fs.readFileSync(workspacePath, "utf-8");
|
|
157
|
+
const patterns = [];
|
|
158
|
+
let isInsidePackagesBlock = false;
|
|
159
|
+
for (const line of content.split("\n")) {
|
|
160
|
+
const trimmed = line.trim();
|
|
161
|
+
if (trimmed === "packages:") {
|
|
162
|
+
isInsidePackagesBlock = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (isInsidePackagesBlock && trimmed.startsWith("-")) patterns.push(trimmed.replace(/^-\s*/, "").replace(/["']/g, ""));
|
|
166
|
+
else if (isInsidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith("#")) isInsidePackagesBlock = false;
|
|
167
|
+
}
|
|
168
|
+
return patterns;
|
|
169
|
+
};
|
|
170
|
+
const getWorkspacePatterns = (rootDirectory, packageJson) => {
|
|
171
|
+
const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory);
|
|
172
|
+
if (pnpmPatterns.length > 0) return pnpmPatterns;
|
|
173
|
+
if (Array.isArray(packageJson.workspaces)) return packageJson.workspaces;
|
|
174
|
+
if (packageJson.workspaces?.packages) return packageJson.workspaces.packages;
|
|
175
|
+
return [];
|
|
176
|
+
};
|
|
177
|
+
const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
|
|
178
|
+
const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
|
|
179
|
+
if (!cleanPattern.includes("*")) {
|
|
180
|
+
const directoryPath = path.join(rootDirectory, cleanPattern);
|
|
181
|
+
if (fs.existsSync(directoryPath) && fs.existsSync(path.join(directoryPath, "package.json"))) return [directoryPath];
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, cleanPattern.indexOf("*")));
|
|
185
|
+
if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
|
|
186
|
+
return fs.readdirSync(baseDirectory).map((entry) => path.join(baseDirectory, entry)).filter((entryPath) => fs.statSync(entryPath).isDirectory() && fs.existsSync(path.join(entryPath, "package.json")));
|
|
187
|
+
};
|
|
188
|
+
const findDependencyInfoFromAncestors = (startDirectory) => {
|
|
189
|
+
let currentDirectory = path.dirname(startDirectory);
|
|
190
|
+
const result = {
|
|
191
|
+
reactVersion: null,
|
|
192
|
+
framework: "unknown"
|
|
193
|
+
};
|
|
194
|
+
while (currentDirectory !== path.dirname(currentDirectory)) {
|
|
195
|
+
const packageJsonPath = path.join(currentDirectory, "package.json");
|
|
196
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
197
|
+
const info = extractDependencyInfo(readPackageJson(packageJsonPath));
|
|
198
|
+
if (!result.reactVersion && info.reactVersion) result.reactVersion = info.reactVersion;
|
|
199
|
+
if (result.framework === "unknown" && info.framework !== "unknown") result.framework = info.framework;
|
|
200
|
+
if (result.reactVersion && result.framework !== "unknown") return result;
|
|
201
|
+
}
|
|
202
|
+
currentDirectory = path.dirname(currentDirectory);
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
};
|
|
206
|
+
const findReactInWorkspaces = (rootDirectory, packageJson) => {
|
|
207
|
+
const patterns = getWorkspacePatterns(rootDirectory, packageJson);
|
|
208
|
+
const result = {
|
|
209
|
+
reactVersion: null,
|
|
210
|
+
framework: "unknown"
|
|
211
|
+
};
|
|
212
|
+
for (const pattern of patterns) {
|
|
213
|
+
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
214
|
+
for (const workspaceDirectory of directories) {
|
|
215
|
+
const info = extractDependencyInfo(readPackageJson(path.join(workspaceDirectory, "package.json")));
|
|
216
|
+
if (info.reactVersion && !result.reactVersion) result.reactVersion = info.reactVersion;
|
|
217
|
+
if (info.framework !== "unknown" && result.framework === "unknown") result.framework = info.framework;
|
|
218
|
+
if (result.reactVersion && result.framework !== "unknown") return result;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
};
|
|
223
|
+
const hasCompilerPackage = (packageJson) => {
|
|
224
|
+
const allDependencies = collectAllDependencies(packageJson);
|
|
225
|
+
return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
|
|
226
|
+
};
|
|
227
|
+
const fileContainsPattern = (filePath, pattern) => {
|
|
228
|
+
if (!fs.existsSync(filePath)) return false;
|
|
229
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
230
|
+
return pattern.test(content);
|
|
231
|
+
};
|
|
232
|
+
const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => fileContainsPattern(path.join(directory, filename), REACT_COMPILER_CONFIG_PATTERN));
|
|
233
|
+
const detectReactCompiler = (directory, packageJson) => {
|
|
234
|
+
if (hasCompilerPackage(packageJson)) return true;
|
|
235
|
+
if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
|
|
236
|
+
if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
|
|
237
|
+
if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
|
|
238
|
+
let ancestorDirectory = path.dirname(directory);
|
|
239
|
+
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
240
|
+
const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
|
|
241
|
+
if (fs.existsSync(ancestorPackagePath)) {
|
|
242
|
+
if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
|
|
243
|
+
}
|
|
244
|
+
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
};
|
|
248
|
+
const discoverProject = (directory) => {
|
|
249
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
250
|
+
if (!fs.existsSync(packageJsonPath)) throw new Error(`No package.json found in ${directory}`);
|
|
251
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
252
|
+
let { reactVersion, framework } = extractDependencyInfo(packageJson);
|
|
253
|
+
if (!reactVersion || framework === "unknown") {
|
|
254
|
+
const workspaceInfo = findReactInWorkspaces(directory, packageJson);
|
|
255
|
+
if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
|
|
256
|
+
if (framework === "unknown" && workspaceInfo.framework !== "unknown") framework = workspaceInfo.framework;
|
|
257
|
+
}
|
|
258
|
+
if (!reactVersion || framework === "unknown") {
|
|
259
|
+
const ancestorInfo = findDependencyInfoFromAncestors(directory);
|
|
260
|
+
if (!reactVersion) reactVersion = ancestorInfo.reactVersion;
|
|
261
|
+
if (framework === "unknown") framework = ancestorInfo.framework;
|
|
262
|
+
}
|
|
263
|
+
const projectName = packageJson.name ?? path.basename(directory);
|
|
264
|
+
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
265
|
+
const sourceFileCount = countSourceFiles(directory);
|
|
266
|
+
const hasReactCompiler = detectReactCompiler(directory, packageJson);
|
|
267
|
+
return {
|
|
268
|
+
rootDirectory: directory,
|
|
269
|
+
projectName,
|
|
270
|
+
reactVersion,
|
|
271
|
+
framework,
|
|
272
|
+
hasTypeScript,
|
|
273
|
+
hasReactCompiler,
|
|
274
|
+
sourceFileCount
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/utils/run-knip.ts
|
|
280
|
+
const KNIP_CATEGORY_MAP = {
|
|
281
|
+
files: "Dead Code",
|
|
282
|
+
exports: "Dead Code",
|
|
283
|
+
types: "Dead Code",
|
|
284
|
+
duplicates: "Dead Code"
|
|
285
|
+
};
|
|
286
|
+
const KNIP_MESSAGE_MAP = {
|
|
287
|
+
files: "Unused file",
|
|
288
|
+
exports: "Unused export",
|
|
289
|
+
types: "Unused type",
|
|
290
|
+
duplicates: "Duplicate export"
|
|
291
|
+
};
|
|
292
|
+
const KNIP_SEVERITY_MAP = {
|
|
293
|
+
files: "warning",
|
|
294
|
+
exports: "warning",
|
|
295
|
+
types: "warning",
|
|
296
|
+
duplicates: "warning"
|
|
297
|
+
};
|
|
298
|
+
const collectIssueRecords = (records, issueType, rootDirectory) => {
|
|
299
|
+
const diagnostics = [];
|
|
300
|
+
for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
|
|
301
|
+
filePath: path.relative(rootDirectory, issue.filePath),
|
|
302
|
+
plugin: "knip",
|
|
303
|
+
rule: issueType,
|
|
304
|
+
severity: KNIP_SEVERITY_MAP[issueType] ?? "warning",
|
|
305
|
+
message: `${KNIP_MESSAGE_MAP[issueType]}: ${issue.symbol}`,
|
|
306
|
+
help: "",
|
|
307
|
+
line: 0,
|
|
308
|
+
column: 0,
|
|
309
|
+
category: KNIP_CATEGORY_MAP[issueType] ?? "Dead Code",
|
|
310
|
+
weight: 1
|
|
311
|
+
});
|
|
312
|
+
return diagnostics;
|
|
313
|
+
};
|
|
314
|
+
const silenced = async (fn) => {
|
|
315
|
+
const originalLog = console.log;
|
|
316
|
+
const originalInfo = console.info;
|
|
317
|
+
const originalWarn = console.warn;
|
|
318
|
+
console.log = () => {};
|
|
319
|
+
console.info = () => {};
|
|
320
|
+
console.warn = () => {};
|
|
321
|
+
try {
|
|
322
|
+
return await fn();
|
|
323
|
+
} finally {
|
|
324
|
+
console.log = originalLog;
|
|
325
|
+
console.info = originalInfo;
|
|
326
|
+
console.warn = originalWarn;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const findMonorepoRoot = (directory) => {
|
|
330
|
+
let currentDirectory = path.dirname(directory);
|
|
331
|
+
while (currentDirectory !== path.dirname(currentDirectory)) {
|
|
332
|
+
if (fs.existsSync(path.join(currentDirectory, "pnpm-workspace.yaml")) || (() => {
|
|
333
|
+
const packageJsonPath = path.join(currentDirectory, "package.json");
|
|
334
|
+
if (!fs.existsSync(packageJsonPath)) return false;
|
|
335
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
336
|
+
return Array.isArray(packageJson.workspaces) || packageJson.workspaces?.packages;
|
|
337
|
+
})()) return currentDirectory;
|
|
338
|
+
currentDirectory = path.dirname(currentDirectory);
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
};
|
|
342
|
+
const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
343
|
+
const options = await silenced(() => createOptions({
|
|
344
|
+
cwd: knipCwd,
|
|
345
|
+
isShowProgress: false,
|
|
346
|
+
...workspaceName ? { workspace: workspaceName } : {}
|
|
347
|
+
}));
|
|
348
|
+
return await silenced(() => main(options));
|
|
349
|
+
};
|
|
350
|
+
const hasNodeModules = (directory) => {
|
|
351
|
+
const nodeModulesPath = path.join(directory, "node_modules");
|
|
352
|
+
return fs.existsSync(nodeModulesPath) && fs.statSync(nodeModulesPath).isDirectory();
|
|
353
|
+
};
|
|
354
|
+
const runKnip = async (rootDirectory) => {
|
|
355
|
+
const monorepoRoot = findMonorepoRoot(rootDirectory);
|
|
356
|
+
if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
|
|
357
|
+
let knipResult;
|
|
358
|
+
if (monorepoRoot) {
|
|
359
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
360
|
+
const workspaceName = (fs.existsSync(packageJsonPath) ? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) : {}).name ?? path.basename(rootDirectory);
|
|
361
|
+
try {
|
|
362
|
+
knipResult = await runKnipWithOptions(monorepoRoot, workspaceName);
|
|
363
|
+
} catch {
|
|
364
|
+
knipResult = await runKnipWithOptions(rootDirectory);
|
|
365
|
+
}
|
|
366
|
+
} else knipResult = await runKnipWithOptions(rootDirectory);
|
|
367
|
+
const { issues } = knipResult;
|
|
368
|
+
const diagnostics = [];
|
|
369
|
+
for (const unusedFile of issues.files) diagnostics.push({
|
|
370
|
+
filePath: path.relative(rootDirectory, unusedFile),
|
|
371
|
+
plugin: "knip",
|
|
372
|
+
rule: "files",
|
|
373
|
+
severity: KNIP_SEVERITY_MAP["files"],
|
|
374
|
+
message: KNIP_MESSAGE_MAP["files"],
|
|
375
|
+
help: "This file is not imported by any other file in the project.",
|
|
376
|
+
line: 0,
|
|
377
|
+
column: 0,
|
|
378
|
+
category: KNIP_CATEGORY_MAP["files"],
|
|
379
|
+
weight: 1
|
|
380
|
+
});
|
|
381
|
+
for (const issueType of [
|
|
382
|
+
"exports",
|
|
383
|
+
"types",
|
|
384
|
+
"duplicates"
|
|
385
|
+
]) diagnostics.push(...collectIssueRecords(issues[issueType], issueType, rootDirectory));
|
|
386
|
+
return diagnostics;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/oxlint-config.ts
|
|
391
|
+
const esmRequire$1 = createRequire(import.meta.url);
|
|
392
|
+
const NEXTJS_RULES = {
|
|
393
|
+
"react-doctor/nextjs-no-img-element": "warn",
|
|
394
|
+
"react-doctor/nextjs-async-client-component": "error",
|
|
395
|
+
"react-doctor/nextjs-no-a-element": "warn",
|
|
396
|
+
"react-doctor/nextjs-no-use-search-params-without-suspense": "warn",
|
|
397
|
+
"react-doctor/nextjs-no-client-fetch-for-server-data": "warn",
|
|
398
|
+
"react-doctor/nextjs-missing-metadata": "warn",
|
|
399
|
+
"react-doctor/nextjs-no-client-side-redirect": "warn",
|
|
400
|
+
"react-doctor/nextjs-no-redirect-in-try-catch": "warn",
|
|
401
|
+
"react-doctor/nextjs-image-missing-sizes": "warn",
|
|
402
|
+
"react-doctor/nextjs-no-native-script": "warn",
|
|
403
|
+
"react-doctor/nextjs-inline-script-missing-id": "warn",
|
|
404
|
+
"react-doctor/nextjs-no-font-link": "warn",
|
|
405
|
+
"react-doctor/nextjs-no-css-link": "warn",
|
|
406
|
+
"react-doctor/nextjs-no-polyfill-script": "warn",
|
|
407
|
+
"react-doctor/nextjs-no-head-import": "error",
|
|
408
|
+
"react-doctor/nextjs-no-side-effect-in-get-handler": "error"
|
|
409
|
+
};
|
|
410
|
+
const REACT_COMPILER_RULES = {
|
|
411
|
+
"react-hooks-js/set-state-in-render": "error",
|
|
412
|
+
"react-hooks-js/immutability": "error",
|
|
413
|
+
"react-hooks-js/refs": "error",
|
|
414
|
+
"react-hooks-js/purity": "error",
|
|
415
|
+
"react-hooks-js/hooks": "error",
|
|
416
|
+
"react-hooks-js/set-state-in-effect": "error",
|
|
417
|
+
"react-hooks-js/globals": "error",
|
|
418
|
+
"react-hooks-js/error-boundaries": "error",
|
|
419
|
+
"react-hooks-js/preserve-manual-memoization": "error",
|
|
420
|
+
"react-hooks-js/unsupported-syntax": "error",
|
|
421
|
+
"react-hooks-js/component-hook-factories": "error",
|
|
422
|
+
"react-hooks-js/static-components": "error",
|
|
423
|
+
"react-hooks-js/use-memo": "error",
|
|
424
|
+
"react-hooks-js/void-use-memo": "error",
|
|
425
|
+
"react-hooks-js/incompatible-library": "error",
|
|
426
|
+
"react-hooks-js/todo": "error"
|
|
427
|
+
};
|
|
428
|
+
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler }) => ({
|
|
429
|
+
categories: {
|
|
430
|
+
correctness: "off",
|
|
431
|
+
suspicious: "off",
|
|
432
|
+
pedantic: "off",
|
|
433
|
+
perf: "off",
|
|
434
|
+
restriction: "off",
|
|
435
|
+
style: "off",
|
|
436
|
+
nursery: "off"
|
|
437
|
+
},
|
|
438
|
+
plugins: [
|
|
439
|
+
"react",
|
|
440
|
+
"jsx-a11y",
|
|
441
|
+
...hasReactCompiler ? [] : ["react-perf"]
|
|
442
|
+
],
|
|
443
|
+
jsPlugins: [...hasReactCompiler ? [{
|
|
444
|
+
name: "react-hooks-js",
|
|
445
|
+
specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
|
|
446
|
+
}] : [], pluginPath],
|
|
447
|
+
rules: {
|
|
448
|
+
"react/rules-of-hooks": "error",
|
|
449
|
+
"react/no-direct-mutation-state": "error",
|
|
450
|
+
"react/jsx-no-duplicate-props": "error",
|
|
451
|
+
"react/jsx-key": "error",
|
|
452
|
+
"react/no-children-prop": "warn",
|
|
453
|
+
"react/no-danger": "warn",
|
|
454
|
+
"react/jsx-no-script-url": "error",
|
|
455
|
+
"react/no-render-return-value": "warn",
|
|
456
|
+
"react/no-string-refs": "warn",
|
|
457
|
+
"react/no-is-mounted": "warn",
|
|
458
|
+
"react/require-render-return": "error",
|
|
459
|
+
"react/no-unknown-property": "warn",
|
|
460
|
+
"jsx-a11y/alt-text": "error",
|
|
461
|
+
"jsx-a11y/anchor-is-valid": "warn",
|
|
462
|
+
"jsx-a11y/click-events-have-key-events": "warn",
|
|
463
|
+
"jsx-a11y/no-static-element-interactions": "warn",
|
|
464
|
+
"jsx-a11y/no-noninteractive-element-interactions": "warn",
|
|
465
|
+
"jsx-a11y/role-has-required-aria-props": "error",
|
|
466
|
+
"jsx-a11y/no-autofocus": "warn",
|
|
467
|
+
"jsx-a11y/heading-has-content": "warn",
|
|
468
|
+
"jsx-a11y/html-has-lang": "warn",
|
|
469
|
+
"jsx-a11y/no-redundant-roles": "warn",
|
|
470
|
+
"jsx-a11y/scope": "warn",
|
|
471
|
+
"jsx-a11y/tabindex-no-positive": "warn",
|
|
472
|
+
"jsx-a11y/label-has-associated-control": "warn",
|
|
473
|
+
"jsx-a11y/no-distracting-elements": "error",
|
|
474
|
+
"jsx-a11y/iframe-has-title": "warn",
|
|
475
|
+
...hasReactCompiler ? REACT_COMPILER_RULES : {},
|
|
476
|
+
"react-doctor/no-derived-state-effect": "error",
|
|
477
|
+
"react-doctor/no-fetch-in-effect": "error",
|
|
478
|
+
"react-doctor/no-cascading-set-state": "warn",
|
|
479
|
+
"react-doctor/no-effect-event-handler": "warn",
|
|
480
|
+
"react-doctor/no-derived-useState": "warn",
|
|
481
|
+
"react-doctor/prefer-useReducer": "warn",
|
|
482
|
+
"react-doctor/rerender-lazy-state-init": "warn",
|
|
483
|
+
"react-doctor/rerender-functional-setstate": "warn",
|
|
484
|
+
"react-doctor/rerender-dependencies": "error",
|
|
485
|
+
"react-doctor/no-giant-component": "warn",
|
|
486
|
+
"react-doctor/no-render-in-render": "warn",
|
|
487
|
+
"react-doctor/no-nested-component-definition": "error",
|
|
488
|
+
"react-doctor/no-usememo-simple-expression": "warn",
|
|
489
|
+
"react-doctor/no-layout-property-animation": "error",
|
|
490
|
+
"react-doctor/rerender-memo-with-default-value": "warn",
|
|
491
|
+
"react-doctor/rendering-animate-svg-wrapper": "warn",
|
|
492
|
+
"react-doctor/no-inline-prop-on-memo-component": "warn",
|
|
493
|
+
"react-doctor/rendering-hydration-no-flicker": "warn",
|
|
494
|
+
"react-doctor/no-transition-all": "warn",
|
|
495
|
+
"react-doctor/no-global-css-variable-animation": "error",
|
|
496
|
+
"react-doctor/no-large-animated-blur": "warn",
|
|
497
|
+
"react-doctor/no-scale-from-zero": "warn",
|
|
498
|
+
"react-doctor/no-permanent-will-change": "warn",
|
|
499
|
+
"react-doctor/no-secrets-in-client-code": "error",
|
|
500
|
+
"react-doctor/no-barrel-import": "warn",
|
|
501
|
+
"react-doctor/no-full-lodash-import": "warn",
|
|
502
|
+
"react-doctor/no-moment": "warn",
|
|
503
|
+
"react-doctor/prefer-dynamic-import": "warn",
|
|
504
|
+
"react-doctor/use-lazy-motion": "warn",
|
|
505
|
+
"react-doctor/no-undeferred-third-party": "warn",
|
|
506
|
+
"react-doctor/no-array-index-as-key": "warn",
|
|
507
|
+
"react-doctor/rendering-conditional-render": "warn",
|
|
508
|
+
"react-doctor/no-prevent-default": "warn",
|
|
509
|
+
"react-doctor/server-auth-actions": "error",
|
|
510
|
+
"react-doctor/server-after-nonblocking": "warn",
|
|
511
|
+
"react-doctor/client-passive-event-listeners": "warn",
|
|
512
|
+
"react-doctor/async-parallel": "warn",
|
|
513
|
+
...framework === "nextjs" ? NEXTJS_RULES : {}
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region src/utils/run-oxlint.ts
|
|
519
|
+
const esmRequire = createRequire(import.meta.url);
|
|
520
|
+
const PLUGIN_CATEGORY_MAP = {
|
|
521
|
+
react: "Correctness",
|
|
522
|
+
"react-hooks": "Correctness",
|
|
523
|
+
"react-hooks-js": "React Compiler",
|
|
524
|
+
"react-perf": "Performance",
|
|
525
|
+
"jsx-a11y": "Accessibility"
|
|
526
|
+
};
|
|
527
|
+
const RULE_CATEGORY_MAP = {
|
|
528
|
+
"react-doctor/no-derived-state-effect": "State & Effects",
|
|
529
|
+
"react-doctor/no-fetch-in-effect": "State & Effects",
|
|
530
|
+
"react-doctor/no-cascading-set-state": "State & Effects",
|
|
531
|
+
"react-doctor/no-effect-event-handler": "State & Effects",
|
|
532
|
+
"react-doctor/no-derived-useState": "State & Effects",
|
|
533
|
+
"react-doctor/prefer-useReducer": "State & Effects",
|
|
534
|
+
"react-doctor/rerender-lazy-state-init": "Performance",
|
|
535
|
+
"react-doctor/rerender-functional-setstate": "Performance",
|
|
536
|
+
"react-doctor/rerender-dependencies": "State & Effects",
|
|
537
|
+
"react-doctor/no-generic-handler-names": "Architecture",
|
|
538
|
+
"react-doctor/no-giant-component": "Architecture",
|
|
539
|
+
"react-doctor/no-render-in-render": "Architecture",
|
|
540
|
+
"react-doctor/no-nested-component-definition": "Correctness",
|
|
541
|
+
"react-doctor/no-usememo-simple-expression": "Performance",
|
|
542
|
+
"react-doctor/no-layout-property-animation": "Performance",
|
|
543
|
+
"react-doctor/rerender-memo-with-default-value": "Performance",
|
|
544
|
+
"react-doctor/rendering-animate-svg-wrapper": "Performance",
|
|
545
|
+
"react-doctor/rendering-usetransition-loading": "Performance",
|
|
546
|
+
"react-doctor/rendering-hydration-no-flicker": "Performance",
|
|
547
|
+
"react-doctor/no-transition-all": "Performance",
|
|
548
|
+
"react-doctor/no-global-css-variable-animation": "Performance",
|
|
549
|
+
"react-doctor/no-large-animated-blur": "Performance",
|
|
550
|
+
"react-doctor/no-scale-from-zero": "Performance",
|
|
551
|
+
"react-doctor/no-permanent-will-change": "Performance",
|
|
552
|
+
"react-doctor/no-secrets-in-client-code": "Security",
|
|
553
|
+
"react-doctor/no-barrel-import": "Bundle Size",
|
|
554
|
+
"react-doctor/no-full-lodash-import": "Bundle Size",
|
|
555
|
+
"react-doctor/no-moment": "Bundle Size",
|
|
556
|
+
"react-doctor/prefer-dynamic-import": "Bundle Size",
|
|
557
|
+
"react-doctor/use-lazy-motion": "Bundle Size",
|
|
558
|
+
"react-doctor/no-undeferred-third-party": "Bundle Size",
|
|
559
|
+
"react-doctor/no-array-index-as-key": "Correctness",
|
|
560
|
+
"react-doctor/rendering-conditional-render": "Correctness",
|
|
561
|
+
"react-doctor/no-prevent-default": "Correctness",
|
|
562
|
+
"react-doctor/nextjs-no-img-element": "Next.js",
|
|
563
|
+
"react-doctor/nextjs-async-client-component": "Next.js",
|
|
564
|
+
"react-doctor/nextjs-no-a-element": "Next.js",
|
|
565
|
+
"react-doctor/nextjs-no-use-search-params-without-suspense": "Next.js",
|
|
566
|
+
"react-doctor/nextjs-no-client-fetch-for-server-data": "Next.js",
|
|
567
|
+
"react-doctor/nextjs-missing-metadata": "Next.js",
|
|
568
|
+
"react-doctor/nextjs-no-client-side-redirect": "Next.js",
|
|
569
|
+
"react-doctor/nextjs-no-redirect-in-try-catch": "Next.js",
|
|
570
|
+
"react-doctor/nextjs-image-missing-sizes": "Next.js",
|
|
571
|
+
"react-doctor/nextjs-no-native-script": "Next.js",
|
|
572
|
+
"react-doctor/nextjs-inline-script-missing-id": "Next.js",
|
|
573
|
+
"react-doctor/nextjs-no-font-link": "Next.js",
|
|
574
|
+
"react-doctor/nextjs-no-css-link": "Next.js",
|
|
575
|
+
"react-doctor/nextjs-no-polyfill-script": "Next.js",
|
|
576
|
+
"react-doctor/nextjs-no-head-import": "Next.js",
|
|
577
|
+
"react-doctor/nextjs-no-side-effect-in-get-handler": "Security",
|
|
578
|
+
"react-doctor/server-auth-actions": "Server",
|
|
579
|
+
"react-doctor/server-after-nonblocking": "Server",
|
|
580
|
+
"react-doctor/client-passive-event-listeners": "Performance",
|
|
581
|
+
"react-doctor/async-parallel": "Performance"
|
|
582
|
+
};
|
|
583
|
+
const RULE_HELP_MAP = {
|
|
584
|
+
"no-derived-state-effect": "Compute during render: `const derived = computeFrom(dep1, dep2)` — no useEffect needed",
|
|
585
|
+
"no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
|
|
586
|
+
"no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
|
|
587
|
+
"no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
|
|
588
|
+
"no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
|
|
589
|
+
"prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
|
|
590
|
+
"rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
|
|
591
|
+
"rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
|
|
592
|
+
"rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
|
|
593
|
+
"no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
|
|
594
|
+
"no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
|
|
595
|
+
"no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
|
|
596
|
+
"no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
|
|
597
|
+
"no-usememo-simple-expression": "Remove useMemo — property access, math, and ternaries are already cheap without memoization",
|
|
598
|
+
"no-layout-property-animation": "Use `transform: translateX()` or `scale()` instead — they run on the compositor and skip layout/paint",
|
|
599
|
+
"rerender-memo-with-default-value": "Move to module scope: `const EMPTY_ITEMS: Item[] = []` then use as the default value",
|
|
600
|
+
"rendering-animate-svg-wrapper": "Wrap the SVG: `<motion.div animate={...}><svg>...</svg></motion.div>`",
|
|
601
|
+
"rendering-usetransition-loading": "Replace with `const [isPending, startTransition] = useTransition()` — avoids a re-render for the loading state",
|
|
602
|
+
"rendering-hydration-no-flicker": "Use `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)` or add `suppressHydrationWarning` to the element",
|
|
603
|
+
"no-transition-all": "List specific properties: `transition: \"opacity 200ms, transform 200ms\"` — or in Tailwind use `transition-colors`, `transition-opacity`, or `transition-transform`",
|
|
604
|
+
"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",
|
|
605
|
+
"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",
|
|
606
|
+
"no-scale-from-zero": "Use `initial={{ scale: 0.95, opacity: 0 }}` — elements should deflate like a balloon, not vanish into a point",
|
|
607
|
+
"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",
|
|
608
|
+
"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)",
|
|
609
|
+
"no-barrel-import": "Import from the direct path: `import { Button } from './components/Button'` instead of `./components`",
|
|
610
|
+
"no-full-lodash-import": "Import the specific function: `import debounce from 'lodash/debounce'` — saves ~70kb",
|
|
611
|
+
"no-moment": "Replace with `import { format } from 'date-fns'` (tree-shakeable) or `import dayjs from 'dayjs'` (2kb)",
|
|
612
|
+
"prefer-dynamic-import": "Use `const Component = dynamic(() => import('library'), { ssr: false })` from next/dynamic or React.lazy()",
|
|
613
|
+
"use-lazy-motion": "Use `import { LazyMotion, m } from \"framer-motion\"` with `domAnimation` features — saves ~30kb",
|
|
614
|
+
"no-undeferred-third-party": "Use `next/script` with `strategy=\"lazyOnload\"` or add the `defer` attribute",
|
|
615
|
+
"no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
|
|
616
|
+
"rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
|
|
617
|
+
"no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
|
|
618
|
+
"nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
|
|
619
|
+
"nextjs-async-client-component": "Fetch data in a parent Server Component and pass it as props, or use useQuery/useSWR in the client component",
|
|
620
|
+
"nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
|
|
621
|
+
"nextjs-no-use-search-params-without-suspense": "Wrap the component using useSearchParams: `<Suspense fallback={<Skeleton />}><SearchComponent /></Suspense>`",
|
|
622
|
+
"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",
|
|
623
|
+
"nextjs-missing-metadata": "Add `export const metadata = { title: '...', description: '...' }` or `export async function generateMetadata()`",
|
|
624
|
+
"nextjs-no-client-side-redirect": "Use `redirect('/path')` from 'next/navigation' in a Server Component, or handle in middleware",
|
|
625
|
+
"nextjs-no-redirect-in-try-catch": "Move the redirect/notFound call outside the try block, or add `unstable_rethrow(error)` in the catch",
|
|
626
|
+
"nextjs-image-missing-sizes": "Add sizes for responsive behavior: `sizes=\"(max-width: 768px) 100vw, 50vw\"` matching your layout breakpoints",
|
|
627
|
+
"nextjs-no-native-script": "`import Script from \"next/script\"` — use `strategy=\"afterInteractive\"` for analytics or `\"lazyOnload\"` for widgets",
|
|
628
|
+
"nextjs-inline-script-missing-id": "Add `id=\"descriptive-name\"` so Next.js can track, deduplicate, and re-execute the script correctly",
|
|
629
|
+
"nextjs-no-font-link": "`import { Inter } from \"next/font/google\"` — self-hosted, zero layout shift, no render-blocking requests",
|
|
630
|
+
"nextjs-no-css-link": "Import CSS directly: `import './styles.css'` or use CSS Modules: `import styles from './Button.module.css'`",
|
|
631
|
+
"nextjs-no-polyfill-script": "Next.js includes polyfills for fetch, Promise, Object.assign, Array.from, and 50+ others automatically",
|
|
632
|
+
"nextjs-no-head-import": "Use the Metadata API instead: `export const metadata = { title: '...' }` or `export async function generateMetadata()`",
|
|
633
|
+
"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",
|
|
634
|
+
"server-auth-actions": "Add `const session = await auth()` at the top and throw/redirect if unauthorized before any data access",
|
|
635
|
+
"server-after-nonblocking": "`import { after } from 'next/server'` then wrap: `after(() => analytics.track(...))` — response isn't blocked",
|
|
636
|
+
"client-passive-event-listeners": "Add `{ passive: true }` as the third argument: `addEventListener('scroll', handler, { passive: true })`",
|
|
637
|
+
"async-parallel": "Use `const [a, b] = await Promise.all([fetchA(), fetchB()])` to run independent operations concurrently"
|
|
638
|
+
};
|
|
639
|
+
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
640
|
+
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
641
|
+
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
642
|
+
if (plugin === "react-hooks-js") return {
|
|
643
|
+
message: REACT_COMPILER_MESSAGE,
|
|
644
|
+
help: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help
|
|
645
|
+
};
|
|
646
|
+
return {
|
|
647
|
+
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
648
|
+
help: help || RULE_HELP_MAP[rule] || ""
|
|
649
|
+
};
|
|
650
|
+
};
|
|
651
|
+
const parseRuleCode = (code) => {
|
|
652
|
+
const match = code.match(/^(.+)\((.+)\)$/);
|
|
653
|
+
if (!match) return {
|
|
654
|
+
plugin: "unknown",
|
|
655
|
+
rule: code
|
|
656
|
+
};
|
|
657
|
+
return {
|
|
658
|
+
plugin: match[1].replace(/^eslint-plugin-/, ""),
|
|
659
|
+
rule: match[2]
|
|
660
|
+
};
|
|
661
|
+
};
|
|
662
|
+
const resolveOxlintBinary = () => {
|
|
663
|
+
const oxlintMainPath = esmRequire.resolve("oxlint");
|
|
664
|
+
const oxlintPackageDirectory = path.resolve(path.dirname(oxlintMainPath), "..");
|
|
665
|
+
return path.join(oxlintPackageDirectory, "bin", "oxlint");
|
|
666
|
+
};
|
|
667
|
+
const resolvePluginPath = () => {
|
|
668
|
+
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
669
|
+
const pluginPath = path.join(currentDirectory, "react-doctor-plugin.js");
|
|
670
|
+
if (fs.existsSync(pluginPath)) return pluginPath;
|
|
671
|
+
const distPluginPath = path.resolve(currentDirectory, "../../dist/react-doctor-plugin.js");
|
|
672
|
+
if (fs.existsSync(distPluginPath)) return distPluginPath;
|
|
673
|
+
return pluginPath;
|
|
674
|
+
};
|
|
675
|
+
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
676
|
+
return RULE_CATEGORY_MAP[`${plugin}/${rule}`] ?? PLUGIN_CATEGORY_MAP[plugin] ?? "Other";
|
|
677
|
+
};
|
|
678
|
+
const runOxlint = async (rootDirectory, hasTypeScript, framework, hasReactCompiler) => {
|
|
679
|
+
const configPath = path.join(os.tmpdir(), `react-doctor-oxlintrc-${process.pid}.json`);
|
|
680
|
+
const config = createOxlintConfig({
|
|
681
|
+
pluginPath: resolvePluginPath(),
|
|
682
|
+
framework,
|
|
683
|
+
hasReactCompiler
|
|
684
|
+
});
|
|
685
|
+
try {
|
|
686
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
687
|
+
const args = [
|
|
688
|
+
resolveOxlintBinary(),
|
|
689
|
+
"-c",
|
|
690
|
+
configPath,
|
|
691
|
+
"--format",
|
|
692
|
+
"json"
|
|
693
|
+
];
|
|
694
|
+
if (hasTypeScript) args.push("--tsconfig", "./tsconfig.json");
|
|
695
|
+
args.push(".");
|
|
696
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
697
|
+
const child = spawn(process.execPath, args, { cwd: rootDirectory });
|
|
698
|
+
const stdoutBuffers = [];
|
|
699
|
+
const stderrBuffers = [];
|
|
700
|
+
child.stdout.on("data", (buffer) => stdoutBuffers.push(buffer));
|
|
701
|
+
child.stderr.on("data", (buffer) => stderrBuffers.push(buffer));
|
|
702
|
+
child.on("error", (error) => reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`)));
|
|
703
|
+
child.on("close", () => {
|
|
704
|
+
const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
|
|
705
|
+
if (!output) {
|
|
706
|
+
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
707
|
+
if (stderrOutput) {
|
|
708
|
+
reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
resolve(output);
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
if (!stdout) return [];
|
|
716
|
+
let output;
|
|
717
|
+
try {
|
|
718
|
+
output = JSON.parse(stdout);
|
|
719
|
+
} catch {
|
|
720
|
+
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, ERROR_PREVIEW_LENGTH_CHARS)}`);
|
|
721
|
+
}
|
|
722
|
+
return output.diagnostics.filter((diagnostic) => JSX_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
723
|
+
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
724
|
+
const primaryLabel = diagnostic.labels[0];
|
|
725
|
+
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
726
|
+
return {
|
|
727
|
+
filePath: diagnostic.filename,
|
|
728
|
+
plugin,
|
|
729
|
+
rule,
|
|
730
|
+
severity: diagnostic.severity,
|
|
731
|
+
message: cleaned.message,
|
|
732
|
+
help: cleaned.help,
|
|
733
|
+
line: primaryLabel?.span.line ?? 0,
|
|
734
|
+
column: primaryLabel?.span.column ?? 0,
|
|
735
|
+
category: resolveDiagnosticCategory(plugin, rule)
|
|
736
|
+
};
|
|
737
|
+
});
|
|
738
|
+
} finally {
|
|
739
|
+
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
//#endregion
|
|
744
|
+
//#region src/index.ts
|
|
745
|
+
const diagnose = async (directory, options = {}) => {
|
|
746
|
+
const { lint = true, deadCode = true } = options;
|
|
747
|
+
const startTime = performance.now();
|
|
748
|
+
const resolvedDirectory = path.resolve(directory);
|
|
749
|
+
const projectInfo = discoverProject(resolvedDirectory);
|
|
750
|
+
if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
|
|
751
|
+
const emptyDiagnostics = () => [];
|
|
752
|
+
const lintPromise = lint ? runOxlint(resolvedDirectory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler).catch(emptyDiagnostics) : Promise.resolve(emptyDiagnostics());
|
|
753
|
+
const deadCodePromise = deadCode ? runKnip(resolvedDirectory).catch(emptyDiagnostics) : Promise.resolve(emptyDiagnostics());
|
|
754
|
+
const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
|
|
755
|
+
const diagnostics = [
|
|
756
|
+
...lintDiagnostics,
|
|
757
|
+
...deadCodeDiagnostics,
|
|
758
|
+
...checkReducedMotion(resolvedDirectory)
|
|
759
|
+
];
|
|
760
|
+
const elapsedMilliseconds = performance.now() - startTime;
|
|
761
|
+
return {
|
|
762
|
+
diagnostics,
|
|
763
|
+
score: await calculateScore(diagnostics),
|
|
764
|
+
project: projectInfo,
|
|
765
|
+
elapsedMilliseconds
|
|
766
|
+
};
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
//#endregion
|
|
770
|
+
export { diagnose };
|
|
771
|
+
//# sourceMappingURL=index.js.map
|