react-doctor 0.2.0-beta.1 → 0.2.0-beta.3
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 +163 -157
- package/dist/cli.js +3998 -20005
- package/dist/index.d.ts +303 -0
- package/dist/index.js +3073 -0
- package/package.json +26 -28
- package/dist/compat-DLFL9-Or.js +0 -1851
- package/dist/compat.d.ts +0 -53
- package/dist/compat.js +0 -3
- package/dist/errors-ZdckckLr.d.ts +0 -87
- package/dist/eslint-plugin-BIjw2MeW.d.ts +0 -105
- package/dist/eslint-plugin.d.ts +0 -2
- package/dist/eslint-plugin.js +0 -51
- package/dist/index-CFzh1cBi.d.ts +0 -1798
- package/dist/metadata-Bz_yY064.js +0 -604
- package/dist/oxlint-plugin.d.ts +0 -2
- package/dist/oxlint-plugin.js +0 -7
- package/dist/rules-DwGNObf8.js +0 -16707
- package/dist/rules-ebKa330H.d.ts +0 -28
- package/dist/score-CzbtoFAu.js +0 -69
- package/dist/score.d.ts +0 -35
- package/dist/score.js +0 -2
- package/dist/sdk.d.ts +0 -90
- package/dist/sdk.js +0 -17
package/dist/index.js
ADDED
|
@@ -0,0 +1,3073 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import reactDoctorPlugin, { ALL_REACT_DOCTOR_RULE_KEYS, FRAMEWORK_SPECIFIC_RULE_KEYS, MOTION_LIBRARY_PACKAGES } from "oxlint-plugin-react-doctor";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import * as ts from "typescript";
|
|
8
|
+
//#region ../project-info/dist/index.js
|
|
9
|
+
var ReactDoctorError = class extends Error {
|
|
10
|
+
name = "ReactDoctorError";
|
|
11
|
+
constructor(message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var ProjectNotFoundError = class extends ReactDoctorError {
|
|
17
|
+
name = "ProjectNotFoundError";
|
|
18
|
+
directory;
|
|
19
|
+
constructor(directory, options) {
|
|
20
|
+
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);
|
|
21
|
+
this.directory = directory;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var NoReactDependencyError = class extends ReactDoctorError {
|
|
25
|
+
name = "NoReactDependencyError";
|
|
26
|
+
directory;
|
|
27
|
+
constructor(directory, options) {
|
|
28
|
+
super(`No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`, options);
|
|
29
|
+
this.directory = directory;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var PackageJsonNotFoundError = class extends ReactDoctorError {
|
|
33
|
+
name = "PackageJsonNotFoundError";
|
|
34
|
+
directory;
|
|
35
|
+
constructor(directory, options) {
|
|
36
|
+
super(`No package.json found in ${directory}`, options);
|
|
37
|
+
this.directory = directory;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var AmbiguousProjectError = class extends ReactDoctorError {
|
|
41
|
+
name = "AmbiguousProjectError";
|
|
42
|
+
directory;
|
|
43
|
+
candidates;
|
|
44
|
+
constructor(directory, candidates, options) {
|
|
45
|
+
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);
|
|
46
|
+
this.directory = directory;
|
|
47
|
+
this.candidates = candidates;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const isReactDoctorError = (value) => value instanceof ReactDoctorError;
|
|
51
|
+
const isFile = (filePath) => {
|
|
52
|
+
try {
|
|
53
|
+
return fs.statSync(filePath).isFile();
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
59
|
+
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
60
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
61
|
+
".git",
|
|
62
|
+
".next",
|
|
63
|
+
".nuxt",
|
|
64
|
+
".output",
|
|
65
|
+
".svelte-kit",
|
|
66
|
+
".turbo",
|
|
67
|
+
"build",
|
|
68
|
+
"coverage",
|
|
69
|
+
"dist",
|
|
70
|
+
"node_modules",
|
|
71
|
+
"out",
|
|
72
|
+
"storybook-static"
|
|
73
|
+
]);
|
|
74
|
+
const countSourceFilesViaFilesystem = (rootDirectory) => {
|
|
75
|
+
let count = 0;
|
|
76
|
+
const stack = [rootDirectory];
|
|
77
|
+
while (stack.length > 0) {
|
|
78
|
+
const currentDirectory = stack.pop();
|
|
79
|
+
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(path.join(currentDirectory, entry.name));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) count++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return count;
|
|
89
|
+
};
|
|
90
|
+
const countSourceFilesViaGit = (rootDirectory) => {
|
|
91
|
+
const result = spawnSync("git", [
|
|
92
|
+
"ls-files",
|
|
93
|
+
"-z",
|
|
94
|
+
"--cached",
|
|
95
|
+
"--others",
|
|
96
|
+
"--exclude-standard"
|
|
97
|
+
], {
|
|
98
|
+
cwd: rootDirectory,
|
|
99
|
+
encoding: "utf-8",
|
|
100
|
+
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
101
|
+
});
|
|
102
|
+
if (result.error || result.status !== 0) return null;
|
|
103
|
+
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath)).length;
|
|
104
|
+
};
|
|
105
|
+
const countSourceFiles = (rootDirectory) => countSourceFilesViaGit(rootDirectory) ?? countSourceFilesViaFilesystem(rootDirectory);
|
|
106
|
+
const cachedPackageJsons = /* @__PURE__ */ new Map();
|
|
107
|
+
const clearPackageJsonCache = () => {
|
|
108
|
+
cachedPackageJsons.clear();
|
|
109
|
+
};
|
|
110
|
+
const readPackageJsonUncached = (packageJsonPath) => {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof SyntaxError) return {};
|
|
115
|
+
if (error instanceof Error && "code" in error) {
|
|
116
|
+
const { code } = error;
|
|
117
|
+
if (code === "EISDIR" || code === "EACCES") return {};
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const readPackageJson = (packageJsonPath) => {
|
|
123
|
+
const absolutePath = path.resolve(packageJsonPath);
|
|
124
|
+
const cached = cachedPackageJsons.get(absolutePath);
|
|
125
|
+
if (cached !== void 0) return cached;
|
|
126
|
+
const result = readPackageJsonUncached(absolutePath);
|
|
127
|
+
cachedPackageJsons.set(absolutePath, result);
|
|
128
|
+
return result;
|
|
129
|
+
};
|
|
130
|
+
const isMonorepoRoot = (directory) => {
|
|
131
|
+
if (isFile(path.join(directory, "pnpm-workspace.yaml"))) return true;
|
|
132
|
+
if (isFile(path.join(directory, "nx.json"))) return true;
|
|
133
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
134
|
+
if (!isFile(packageJsonPath)) return false;
|
|
135
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
136
|
+
return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces?.packages);
|
|
137
|
+
};
|
|
138
|
+
const findMonorepoRoot = (startDirectory) => {
|
|
139
|
+
let currentDirectory = path.dirname(startDirectory);
|
|
140
|
+
while (currentDirectory !== path.dirname(currentDirectory)) {
|
|
141
|
+
if (isMonorepoRoot(currentDirectory)) return currentDirectory;
|
|
142
|
+
currentDirectory = path.dirname(currentDirectory);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
const REACT_COMPILER_PACKAGES = new Set([
|
|
147
|
+
"babel-plugin-react-compiler",
|
|
148
|
+
"react-compiler-runtime",
|
|
149
|
+
"eslint-plugin-react-compiler"
|
|
150
|
+
]);
|
|
151
|
+
const NEXT_CONFIG_FILENAMES = [
|
|
152
|
+
"next.config.js",
|
|
153
|
+
"next.config.mjs",
|
|
154
|
+
"next.config.ts",
|
|
155
|
+
"next.config.cjs"
|
|
156
|
+
];
|
|
157
|
+
const BABEL_CONFIG_FILENAMES = [
|
|
158
|
+
".babelrc",
|
|
159
|
+
".babelrc.json",
|
|
160
|
+
"babel.config.js",
|
|
161
|
+
"babel.config.json",
|
|
162
|
+
"babel.config.cjs",
|
|
163
|
+
"babel.config.mjs"
|
|
164
|
+
];
|
|
165
|
+
const VITE_CONFIG_FILENAMES = [
|
|
166
|
+
"vite.config.js",
|
|
167
|
+
"vite.config.ts",
|
|
168
|
+
"vite.config.mjs",
|
|
169
|
+
"vite.config.mts",
|
|
170
|
+
"vite.config.cjs",
|
|
171
|
+
"vite.config.cts",
|
|
172
|
+
"vitest.config.ts",
|
|
173
|
+
"vitest.config.js"
|
|
174
|
+
];
|
|
175
|
+
const EXPO_APP_CONFIG_FILENAMES = [
|
|
176
|
+
"app.json",
|
|
177
|
+
"app.config.js",
|
|
178
|
+
"app.config.ts"
|
|
179
|
+
];
|
|
180
|
+
const REACT_COMPILER_PACKAGE_REFERENCE_PATTERN = /babel-plugin-react-compiler|react-compiler-runtime|eslint-plugin-react-compiler|["']react-compiler["']/;
|
|
181
|
+
const REACT_COMPILER_ENABLED_FLAG_PATTERN = /["']?reactCompiler["']?\s*:\s*(?:true\b|\{)/;
|
|
182
|
+
const hasCompilerPackage = (packageJson) => {
|
|
183
|
+
const allDependencies = {
|
|
184
|
+
...packageJson.peerDependencies,
|
|
185
|
+
...packageJson.dependencies,
|
|
186
|
+
...packageJson.devDependencies
|
|
187
|
+
};
|
|
188
|
+
return Object.keys(allDependencies).some((packageName) => REACT_COMPILER_PACKAGES.has(packageName));
|
|
189
|
+
};
|
|
190
|
+
const hasCompilerInConfigFile = (filePath) => {
|
|
191
|
+
if (!isFile(filePath)) return false;
|
|
192
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
193
|
+
return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
|
|
194
|
+
};
|
|
195
|
+
const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
|
|
196
|
+
const isProjectBoundary$2 = (directory) => {
|
|
197
|
+
if (fs.existsSync(path.join(directory, ".git"))) return true;
|
|
198
|
+
return isMonorepoRoot(directory);
|
|
199
|
+
};
|
|
200
|
+
const detectReactCompiler = (directory, packageJson) => {
|
|
201
|
+
if (hasCompilerPackage(packageJson)) return true;
|
|
202
|
+
if (hasCompilerInConfigFiles(directory, NEXT_CONFIG_FILENAMES)) return true;
|
|
203
|
+
if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
|
|
204
|
+
if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
|
|
205
|
+
if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
|
|
206
|
+
if (isProjectBoundary$2(directory)) return false;
|
|
207
|
+
let ancestorDirectory = path.dirname(directory);
|
|
208
|
+
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
209
|
+
const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
|
|
210
|
+
if (isFile(ancestorPackagePath)) {
|
|
211
|
+
if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
|
|
212
|
+
}
|
|
213
|
+
if (isProjectBoundary$2(ancestorDirectory)) return false;
|
|
214
|
+
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
};
|
|
218
|
+
const FRAMEWORK_PACKAGES = {
|
|
219
|
+
next: "nextjs",
|
|
220
|
+
"@tanstack/react-start": "tanstack-start",
|
|
221
|
+
vite: "vite",
|
|
222
|
+
"react-scripts": "cra",
|
|
223
|
+
"@remix-run/react": "remix",
|
|
224
|
+
gatsby: "gatsby",
|
|
225
|
+
expo: "expo",
|
|
226
|
+
"react-native": "react-native"
|
|
227
|
+
};
|
|
228
|
+
const detectFramework = (dependencies) => {
|
|
229
|
+
for (const [packageName, frameworkName] of Object.entries(FRAMEWORK_PACKAGES)) if (dependencies[packageName]) return frameworkName;
|
|
230
|
+
return "unknown";
|
|
231
|
+
};
|
|
232
|
+
const UPPER_BOUND_COMPARATOR = /<\s*=?\s*\d+(?:\.\d+){0,2}(?:-[^\s,|]+)?/g;
|
|
233
|
+
const HAS_UPPER_BOUND_COMPARATOR = /<\s*=?\s*\d+(?:\.\d+){0,2}(?:-[^\s,|]+)?/;
|
|
234
|
+
const OR_SEPARATOR = /\s*\|\|\s*/;
|
|
235
|
+
const UNRESOLVABLE_PROTOCOL_VERSION = /^(?:file|git|github|https?|link|patch|portal|workspace|npm):/i;
|
|
236
|
+
const DIST_TAG_VERSION = /^[a-z][a-z0-9._-]*$/i;
|
|
237
|
+
const WILDCARD_VERSION = /^[*xX](?:\.[*xX])*$/;
|
|
238
|
+
const NON_LOWER_BOUND_COMPARATOR = /(?:^|[\s,|])(?:>(?!=)|!={0,2})\s*\d/;
|
|
239
|
+
const LOWER_BOUND_MAJOR = /(?:^|[\s,|])(?:>=\s*|[~^=v]\s*)?(\d+)(?=$|[\s,|.*xX-])/g;
|
|
240
|
+
const NPM_ALIAS_VERSION = /^npm:(?:@[^/]+\/[^@]+|[^@]+)@(.+)$/i;
|
|
241
|
+
const normalizeDependencyVersion = (version) => {
|
|
242
|
+
const trimmed = version.trim();
|
|
243
|
+
if (trimmed.length === 0) return null;
|
|
244
|
+
const normalizedVersion = trimmed.match(NPM_ALIAS_VERSION)?.[1]?.trim() ?? trimmed;
|
|
245
|
+
if (UNRESOLVABLE_PROTOCOL_VERSION.test(normalizedVersion)) return null;
|
|
246
|
+
if (DIST_TAG_VERSION.test(normalizedVersion) && !/^v\d/i.test(normalizedVersion)) return null;
|
|
247
|
+
if (WILDCARD_VERSION.test(normalizedVersion)) return null;
|
|
248
|
+
return normalizedVersion;
|
|
249
|
+
};
|
|
250
|
+
const splitDependencyVersionBranches = (version) => version.split(OR_SEPARATOR).filter(Boolean);
|
|
251
|
+
const hasUpperBoundComparator = (version) => HAS_UPPER_BOUND_COMPARATOR.test(version);
|
|
252
|
+
const getBranchLowestMajor = (branch) => {
|
|
253
|
+
if (NON_LOWER_BOUND_COMPARATOR.test(branch)) return null;
|
|
254
|
+
const lowerBoundComparators = branch.replace(UPPER_BOUND_COMPARATOR, " ").trim();
|
|
255
|
+
if (lowerBoundComparators.length === 0) return null;
|
|
256
|
+
let branchLowestMajor = null;
|
|
257
|
+
for (const match of lowerBoundComparators.matchAll(LOWER_BOUND_MAJOR)) {
|
|
258
|
+
const major = Number.parseInt(match[1], 10);
|
|
259
|
+
if (!Number.isFinite(major) || major <= 0) continue;
|
|
260
|
+
if (branchLowestMajor === null || major < branchLowestMajor) branchLowestMajor = major;
|
|
261
|
+
}
|
|
262
|
+
return branchLowestMajor;
|
|
263
|
+
};
|
|
264
|
+
const getLowestDependencyMajor = (version) => {
|
|
265
|
+
const normalizedVersion = normalizeDependencyVersion(version);
|
|
266
|
+
if (normalizedVersion === null) return null;
|
|
267
|
+
let lowestMajor = null;
|
|
268
|
+
for (const branch of splitDependencyVersionBranches(normalizedVersion)) {
|
|
269
|
+
const normalizedBranch = normalizeDependencyVersion(branch);
|
|
270
|
+
if (normalizedBranch === null) return null;
|
|
271
|
+
const branchLowestMajor = getBranchLowestMajor(normalizedBranch);
|
|
272
|
+
if (branchLowestMajor === null && hasUpperBoundComparator(normalizedBranch)) return null;
|
|
273
|
+
if (branchLowestMajor !== null && (lowestMajor === null || branchLowestMajor < lowestMajor)) lowestMajor = branchLowestMajor;
|
|
274
|
+
}
|
|
275
|
+
return lowestMajor;
|
|
276
|
+
};
|
|
277
|
+
const isConcreteDependencyVersion = (version) => {
|
|
278
|
+
const normalizedVersion = normalizeDependencyVersion(version);
|
|
279
|
+
return normalizedVersion !== null && /\d/.test(normalizedVersion);
|
|
280
|
+
};
|
|
281
|
+
const isPlainObject = (value) => {
|
|
282
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
283
|
+
const prototype = Object.getPrototypeOf(value);
|
|
284
|
+
return prototype === null || prototype === Object.prototype;
|
|
285
|
+
};
|
|
286
|
+
const isCatalogReference = (version) => version.startsWith("catalog:");
|
|
287
|
+
const extractCatalogName = (version) => {
|
|
288
|
+
if (!isCatalogReference(version)) return null;
|
|
289
|
+
const name = version.slice(8).trim();
|
|
290
|
+
return name.length > 0 ? name : null;
|
|
291
|
+
};
|
|
292
|
+
const resolveVersionFromCatalog = (catalog, packageName) => {
|
|
293
|
+
const version = catalog[packageName];
|
|
294
|
+
if (typeof version === "string" && !isCatalogReference(version)) return version;
|
|
295
|
+
return null;
|
|
296
|
+
};
|
|
297
|
+
const parsePnpmWorkspaceCatalogs = (rootDirectory) => {
|
|
298
|
+
const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
|
|
299
|
+
if (!isFile(workspacePath)) return {
|
|
300
|
+
defaultCatalog: {},
|
|
301
|
+
namedCatalogs: {}
|
|
302
|
+
};
|
|
303
|
+
const content = fs.readFileSync(workspacePath, "utf-8");
|
|
304
|
+
const defaultCatalog = {};
|
|
305
|
+
const namedCatalogs = {};
|
|
306
|
+
let currentSection = "none";
|
|
307
|
+
let currentCatalogName = "";
|
|
308
|
+
for (const line of content.split("\n")) {
|
|
309
|
+
const trimmed = line.trim();
|
|
310
|
+
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
|
311
|
+
const indentLevel = line.search(/\S/);
|
|
312
|
+
if (indentLevel === 0 && trimmed === "catalog:") {
|
|
313
|
+
currentSection = "catalog";
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (indentLevel === 0 && trimmed === "catalogs:") {
|
|
317
|
+
currentSection = "catalogs";
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (indentLevel === 0) {
|
|
321
|
+
currentSection = "none";
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (currentSection === "catalog" && indentLevel > 0) {
|
|
325
|
+
const colonIndex = trimmed.indexOf(":");
|
|
326
|
+
if (colonIndex > 0) {
|
|
327
|
+
const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
|
|
328
|
+
const value = trimmed.slice(colonIndex + 1).trim().replace(/["']/g, "");
|
|
329
|
+
if (key && value) defaultCatalog[key] = value;
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (currentSection === "catalogs" && indentLevel > 0) {
|
|
334
|
+
if (trimmed.endsWith(":") && !trimmed.includes(" ")) {
|
|
335
|
+
currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
|
|
336
|
+
currentSection = "named-catalog";
|
|
337
|
+
namedCatalogs[currentCatalogName] = {};
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (currentSection === "named-catalog" && indentLevel > 0) {
|
|
342
|
+
if (indentLevel <= 2 && trimmed.endsWith(":") && !trimmed.includes(" ")) {
|
|
343
|
+
currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
|
|
344
|
+
namedCatalogs[currentCatalogName] = {};
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const colonIndex = trimmed.indexOf(":");
|
|
348
|
+
if (colonIndex > 0 && currentCatalogName) {
|
|
349
|
+
const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
|
|
350
|
+
const value = trimmed.slice(colonIndex + 1).trim().replace(/["']/g, "");
|
|
351
|
+
if (key && value) namedCatalogs[currentCatalogName][key] = value;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
defaultCatalog,
|
|
357
|
+
namedCatalogs
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
const resolveCatalogVersionFromCollection = (catalogs, packageName, options) => {
|
|
361
|
+
const { catalogReference, shouldSearchUnreferencedNamedCatalogs } = options;
|
|
362
|
+
if (catalogReference) {
|
|
363
|
+
const namedCatalog = catalogs.namedCatalogs[catalogReference];
|
|
364
|
+
if (namedCatalog?.[packageName]) return namedCatalog[packageName];
|
|
365
|
+
}
|
|
366
|
+
if (catalogs.defaultCatalog[packageName]) return catalogs.defaultCatalog[packageName];
|
|
367
|
+
if (!shouldSearchUnreferencedNamedCatalogs) return null;
|
|
368
|
+
for (const namedCatalog of Object.values(catalogs.namedCatalogs)) if (namedCatalog[packageName]) return namedCatalog[packageName];
|
|
369
|
+
return null;
|
|
370
|
+
};
|
|
371
|
+
const resolveCatalogVersion = (packageJson, packageName, rootDirectory, explicitCatalogReference) => {
|
|
372
|
+
const rawVersion = {
|
|
373
|
+
...packageJson.peerDependencies,
|
|
374
|
+
...packageJson.dependencies,
|
|
375
|
+
...packageJson.devDependencies
|
|
376
|
+
}[packageName];
|
|
377
|
+
const hasExplicitCatalogReference = explicitCatalogReference !== void 0;
|
|
378
|
+
const catalogName = hasExplicitCatalogReference ? explicitCatalogReference : rawVersion ? extractCatalogName(rawVersion) : null;
|
|
379
|
+
const shouldSearchUnreferencedNamedCatalogs = !hasExplicitCatalogReference && catalogName === null;
|
|
380
|
+
if (isPlainObject(packageJson.catalog)) {
|
|
381
|
+
const version = resolveVersionFromCatalog(packageJson.catalog, packageName);
|
|
382
|
+
if (version) return version;
|
|
383
|
+
}
|
|
384
|
+
if (isPlainObject(packageJson.catalogs)) {
|
|
385
|
+
const namedCatalog = catalogName ? packageJson.catalogs[catalogName] : void 0;
|
|
386
|
+
if (namedCatalog && isPlainObject(namedCatalog)) {
|
|
387
|
+
const version = resolveVersionFromCatalog(namedCatalog, packageName);
|
|
388
|
+
if (version) return version;
|
|
389
|
+
}
|
|
390
|
+
if (shouldSearchUnreferencedNamedCatalogs) {
|
|
391
|
+
for (const catalogEntries of Object.values(packageJson.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
392
|
+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
393
|
+
if (version) return version;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const workspaces = packageJson.workspaces;
|
|
398
|
+
if (workspaces && !Array.isArray(workspaces)) {
|
|
399
|
+
if (isPlainObject(workspaces.catalog)) {
|
|
400
|
+
const version = resolveVersionFromCatalog(workspaces.catalog, packageName);
|
|
401
|
+
if (version) return version;
|
|
402
|
+
}
|
|
403
|
+
if (isPlainObject(workspaces.catalogs)) {
|
|
404
|
+
const namedCatalog = catalogName ? workspaces.catalogs[catalogName] : void 0;
|
|
405
|
+
if (namedCatalog && isPlainObject(namedCatalog)) {
|
|
406
|
+
const version = resolveVersionFromCatalog(namedCatalog, packageName);
|
|
407
|
+
if (version) return version;
|
|
408
|
+
}
|
|
409
|
+
if (shouldSearchUnreferencedNamedCatalogs) {
|
|
410
|
+
for (const catalogEntries of Object.values(workspaces.catalogs)) if (isPlainObject(catalogEntries)) {
|
|
411
|
+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
|
|
412
|
+
if (version) return version;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (rootDirectory) {
|
|
418
|
+
const pnpmVersion = resolveCatalogVersionFromCollection(parsePnpmWorkspaceCatalogs(rootDirectory), packageName, {
|
|
419
|
+
catalogReference: catalogName,
|
|
420
|
+
shouldSearchUnreferencedNamedCatalogs
|
|
421
|
+
});
|
|
422
|
+
if (pnpmVersion) return pnpmVersion;
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
};
|
|
426
|
+
const EMPTY_DEPENDENCY_INFO = {
|
|
427
|
+
reactVersion: null,
|
|
428
|
+
tailwindVersion: null,
|
|
429
|
+
framework: "unknown"
|
|
430
|
+
};
|
|
431
|
+
const pickConcreteVersion = (packageJson, packageName, sections) => {
|
|
432
|
+
for (const section of sections) {
|
|
433
|
+
const version = packageJson[section]?.[packageName];
|
|
434
|
+
if (version === void 0) continue;
|
|
435
|
+
if (isCatalogReference(version)) return null;
|
|
436
|
+
if (isConcreteDependencyVersion(version)) return version;
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
};
|
|
440
|
+
const extractDependencyInfo = (packageJson) => {
|
|
441
|
+
const allDependencies = {
|
|
442
|
+
...packageJson.peerDependencies,
|
|
443
|
+
...packageJson.dependencies,
|
|
444
|
+
...packageJson.devDependencies
|
|
445
|
+
};
|
|
446
|
+
return {
|
|
447
|
+
reactVersion: pickConcreteVersion(packageJson, "react", [
|
|
448
|
+
"dependencies",
|
|
449
|
+
"peerDependencies",
|
|
450
|
+
"devDependencies"
|
|
451
|
+
]),
|
|
452
|
+
tailwindVersion: pickConcreteVersion(packageJson, "tailwindcss", [
|
|
453
|
+
"dependencies",
|
|
454
|
+
"devDependencies",
|
|
455
|
+
"peerDependencies"
|
|
456
|
+
]),
|
|
457
|
+
framework: detectFramework(allDependencies)
|
|
458
|
+
};
|
|
459
|
+
};
|
|
460
|
+
const getDependencyDeclaration = ({ packageJson, packageName, sections }) => {
|
|
461
|
+
for (const section of sections) {
|
|
462
|
+
const version = packageJson[section]?.[packageName];
|
|
463
|
+
if (version === void 0) continue;
|
|
464
|
+
return {
|
|
465
|
+
catalogReference: extractCatalogName(version) ?? null,
|
|
466
|
+
hasDeclaration: true,
|
|
467
|
+
version
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
catalogReference: null,
|
|
472
|
+
hasDeclaration: false,
|
|
473
|
+
version: null
|
|
474
|
+
};
|
|
475
|
+
};
|
|
476
|
+
const NX_PROJECT_DISCOVERY_DIRS = [
|
|
477
|
+
"apps",
|
|
478
|
+
"libs",
|
|
479
|
+
"packages"
|
|
480
|
+
];
|
|
481
|
+
const getNxWorkspaceDirectories = (rootDirectory) => {
|
|
482
|
+
if (!isFile(path.join(rootDirectory, "nx.json"))) return [];
|
|
483
|
+
const collected = [];
|
|
484
|
+
for (const candidate of NX_PROJECT_DISCOVERY_DIRS) {
|
|
485
|
+
const candidatePath = path.join(rootDirectory, candidate);
|
|
486
|
+
if (!fs.existsSync(candidatePath) || !fs.statSync(candidatePath).isDirectory()) continue;
|
|
487
|
+
for (const entry of fs.readdirSync(candidatePath, { withFileTypes: true })) {
|
|
488
|
+
if (!entry.isDirectory()) continue;
|
|
489
|
+
const projectDirectory = path.join(candidatePath, entry.name);
|
|
490
|
+
if (isFile(path.join(projectDirectory, "project.json")) || isFile(path.join(projectDirectory, "package.json"))) collected.push(`${candidate}/${entry.name}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return collected;
|
|
494
|
+
};
|
|
495
|
+
const parsePnpmWorkspacePatterns = (rootDirectory) => {
|
|
496
|
+
const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
|
|
497
|
+
if (!isFile(workspacePath)) return [];
|
|
498
|
+
const content = fs.readFileSync(workspacePath, "utf-8");
|
|
499
|
+
const patterns = [];
|
|
500
|
+
let isInsidePackagesBlock = false;
|
|
501
|
+
for (const line of content.split("\n")) {
|
|
502
|
+
const trimmed = line.trim();
|
|
503
|
+
if (trimmed === "packages:") {
|
|
504
|
+
isInsidePackagesBlock = true;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (isInsidePackagesBlock && trimmed.startsWith("-")) patterns.push(trimmed.replace(/^-\s*/, "").replace(/["']/g, ""));
|
|
508
|
+
else if (isInsidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith("#")) isInsidePackagesBlock = false;
|
|
509
|
+
}
|
|
510
|
+
return patterns;
|
|
511
|
+
};
|
|
512
|
+
const getWorkspacePatterns = (rootDirectory, packageJson) => {
|
|
513
|
+
const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory);
|
|
514
|
+
if (pnpmPatterns.length > 0) return pnpmPatterns;
|
|
515
|
+
if (Array.isArray(packageJson.workspaces)) return packageJson.workspaces;
|
|
516
|
+
if (packageJson.workspaces?.packages) return packageJson.workspaces.packages;
|
|
517
|
+
const nxPatterns = getNxWorkspaceDirectories(rootDirectory);
|
|
518
|
+
if (nxPatterns.length > 0) return nxPatterns;
|
|
519
|
+
return [];
|
|
520
|
+
};
|
|
521
|
+
const parseReactMajor = (reactVersion) => {
|
|
522
|
+
if (typeof reactVersion !== "string") return null;
|
|
523
|
+
return getLowestDependencyMajor(reactVersion);
|
|
524
|
+
};
|
|
525
|
+
const resolveWorkspaceDirectories = (rootDirectory, pattern) => {
|
|
526
|
+
const cleanPattern = pattern.replace(/["']/g, "").replace(/\/\*\*$/, "/*");
|
|
527
|
+
if (!cleanPattern.includes("*")) {
|
|
528
|
+
const directoryPath = path.join(rootDirectory, cleanPattern);
|
|
529
|
+
if (fs.existsSync(directoryPath) && isFile(path.join(directoryPath, "package.json"))) return [directoryPath];
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
const wildcardIndex = cleanPattern.indexOf("*");
|
|
533
|
+
const baseDirectory = path.join(rootDirectory, cleanPattern.slice(0, wildcardIndex));
|
|
534
|
+
const suffixAfterWildcard = cleanPattern.slice(wildcardIndex + 1);
|
|
535
|
+
if (!fs.existsSync(baseDirectory) || !fs.statSync(baseDirectory).isDirectory()) return [];
|
|
536
|
+
const resolved = [];
|
|
537
|
+
for (const entry of fs.readdirSync(baseDirectory)) {
|
|
538
|
+
const entryPath = path.join(baseDirectory, entry, suffixAfterWildcard);
|
|
539
|
+
if (fs.existsSync(entryPath) && fs.statSync(entryPath).isDirectory() && isFile(path.join(entryPath, "package.json"))) resolved.push(entryPath);
|
|
540
|
+
}
|
|
541
|
+
return resolved;
|
|
542
|
+
};
|
|
543
|
+
const resolveWorkspaceDependencyVersion = ({ concreteVersion, packageName, rootDirectory, rootPackageJson, sections, workspaceDirectory, workspacePackageJson }) => {
|
|
544
|
+
const dependencyDeclaration = getDependencyDeclaration({
|
|
545
|
+
packageJson: workspacePackageJson,
|
|
546
|
+
packageName,
|
|
547
|
+
sections
|
|
548
|
+
});
|
|
549
|
+
if (!dependencyDeclaration.hasDeclaration) return null;
|
|
550
|
+
return concreteVersion ?? resolveCatalogVersion(workspacePackageJson, packageName, workspaceDirectory, dependencyDeclaration.catalogReference) ?? resolveCatalogVersion(rootPackageJson, packageName, rootDirectory, dependencyDeclaration.catalogReference);
|
|
551
|
+
};
|
|
552
|
+
const shouldReplaceReactVersion = (currentVersion, nextVersion) => {
|
|
553
|
+
if (!currentVersion) return true;
|
|
554
|
+
const currentMajor = parseReactMajor(currentVersion);
|
|
555
|
+
const nextMajor = parseReactMajor(nextVersion);
|
|
556
|
+
if (currentMajor === null) return nextMajor !== null;
|
|
557
|
+
if (nextMajor === null) return false;
|
|
558
|
+
return nextMajor < currentMajor;
|
|
559
|
+
};
|
|
560
|
+
const findReactInWorkspaces = (rootDirectory, packageJson) => {
|
|
561
|
+
const patterns = getWorkspacePatterns(rootDirectory, packageJson);
|
|
562
|
+
const result = { ...EMPTY_DEPENDENCY_INFO };
|
|
563
|
+
for (const pattern of patterns) {
|
|
564
|
+
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
565
|
+
for (const workspaceDirectory of directories) {
|
|
566
|
+
const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, "package.json"));
|
|
567
|
+
const info = extractDependencyInfo(workspacePackageJson);
|
|
568
|
+
const reactVersion = resolveWorkspaceDependencyVersion({
|
|
569
|
+
concreteVersion: info.reactVersion,
|
|
570
|
+
packageName: "react",
|
|
571
|
+
rootDirectory,
|
|
572
|
+
rootPackageJson: packageJson,
|
|
573
|
+
sections: [
|
|
574
|
+
"dependencies",
|
|
575
|
+
"peerDependencies",
|
|
576
|
+
"devDependencies"
|
|
577
|
+
],
|
|
578
|
+
workspaceDirectory,
|
|
579
|
+
workspacePackageJson
|
|
580
|
+
});
|
|
581
|
+
const tailwindVersion = resolveWorkspaceDependencyVersion({
|
|
582
|
+
concreteVersion: info.tailwindVersion,
|
|
583
|
+
packageName: "tailwindcss",
|
|
584
|
+
rootDirectory,
|
|
585
|
+
rootPackageJson: packageJson,
|
|
586
|
+
sections: [
|
|
587
|
+
"dependencies",
|
|
588
|
+
"devDependencies",
|
|
589
|
+
"peerDependencies"
|
|
590
|
+
],
|
|
591
|
+
workspaceDirectory,
|
|
592
|
+
workspacePackageJson
|
|
593
|
+
});
|
|
594
|
+
if (reactVersion && shouldReplaceReactVersion(result.reactVersion, reactVersion)) result.reactVersion = reactVersion;
|
|
595
|
+
if (tailwindVersion && !result.tailwindVersion) result.tailwindVersion = tailwindVersion;
|
|
596
|
+
if (info.framework !== "unknown" && result.framework === "unknown") result.framework = info.framework;
|
|
597
|
+
const resultReactMajor = parseReactMajor(result.reactVersion);
|
|
598
|
+
if (result.reactVersion && result.tailwindVersion && result.framework !== "unknown" && resultReactMajor !== null && resultReactMajor <= 17) return result;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return result;
|
|
602
|
+
};
|
|
603
|
+
const REACT_DEPENDENCY_NAMES = new Set([
|
|
604
|
+
"react",
|
|
605
|
+
"react-native",
|
|
606
|
+
"next"
|
|
607
|
+
]);
|
|
608
|
+
const hasReactDependency = (packageJson) => {
|
|
609
|
+
const allDependencies = {
|
|
610
|
+
...packageJson.peerDependencies,
|
|
611
|
+
...packageJson.dependencies,
|
|
612
|
+
...packageJson.devDependencies
|
|
613
|
+
};
|
|
614
|
+
return Object.keys(allDependencies).some((packageName) => REACT_DEPENDENCY_NAMES.has(packageName));
|
|
615
|
+
};
|
|
616
|
+
const findDependencyInfoFromMonorepoRoot = (directory) => {
|
|
617
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
618
|
+
if (!monorepoRoot) return EMPTY_DEPENDENCY_INFO;
|
|
619
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
620
|
+
if (!isFile(monorepoPackageJsonPath)) return EMPTY_DEPENDENCY_INFO;
|
|
621
|
+
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
622
|
+
const rootInfo = extractDependencyInfo(rootPackageJson);
|
|
623
|
+
const leafPackageJsonPath = path.join(directory, "package.json");
|
|
624
|
+
const leafPackageJson = isFile(leafPackageJsonPath) ? readPackageJson(leafPackageJsonPath) : null;
|
|
625
|
+
const leafReactDeclaration = leafPackageJson ? getDependencyDeclaration({
|
|
626
|
+
packageJson: leafPackageJson,
|
|
627
|
+
packageName: "react",
|
|
628
|
+
sections: [
|
|
629
|
+
"dependencies",
|
|
630
|
+
"peerDependencies",
|
|
631
|
+
"devDependencies"
|
|
632
|
+
]
|
|
633
|
+
}) : null;
|
|
634
|
+
const leafTailwindDeclaration = leafPackageJson ? getDependencyDeclaration({
|
|
635
|
+
packageJson: leafPackageJson,
|
|
636
|
+
packageName: "tailwindcss",
|
|
637
|
+
sections: [
|
|
638
|
+
"dependencies",
|
|
639
|
+
"devDependencies",
|
|
640
|
+
"peerDependencies"
|
|
641
|
+
]
|
|
642
|
+
}) : null;
|
|
643
|
+
const shouldUseReactFallback = leafPackageJson ? hasReactDependency(leafPackageJson) : true;
|
|
644
|
+
const shouldUseTailwindFallback = leafTailwindDeclaration?.hasDeclaration ?? true;
|
|
645
|
+
const reactCatalogVersion = shouldUseReactFallback ? resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, leafReactDeclaration?.catalogReference) : null;
|
|
646
|
+
const tailwindCatalogVersion = shouldUseTailwindFallback ? resolveCatalogVersion(rootPackageJson, "tailwindcss", monorepoRoot, leafTailwindDeclaration?.catalogReference) : null;
|
|
647
|
+
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
|
|
648
|
+
return {
|
|
649
|
+
reactVersion: shouldUseReactFallback ? reactCatalogVersion ?? rootInfo.reactVersion ?? workspaceInfo.reactVersion : null,
|
|
650
|
+
tailwindVersion: shouldUseTailwindFallback ? tailwindCatalogVersion ?? rootInfo.tailwindVersion ?? workspaceInfo.tailwindVersion : null,
|
|
651
|
+
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework
|
|
652
|
+
};
|
|
653
|
+
};
|
|
654
|
+
const TANSTACK_QUERY_PACKAGES = new Set([
|
|
655
|
+
"@tanstack/react-query",
|
|
656
|
+
"@tanstack/query-core",
|
|
657
|
+
"react-query"
|
|
658
|
+
]);
|
|
659
|
+
const hasTanStackQuery = (packageJson) => {
|
|
660
|
+
const allDependencies = {
|
|
661
|
+
...packageJson.peerDependencies,
|
|
662
|
+
...packageJson.dependencies,
|
|
663
|
+
...packageJson.devDependencies
|
|
664
|
+
};
|
|
665
|
+
return Object.keys(allDependencies).some((packageName) => TANSTACK_QUERY_PACKAGES.has(packageName));
|
|
666
|
+
};
|
|
667
|
+
const hasUpperBoundOnlyPeerRange = (range) => {
|
|
668
|
+
if (typeof range !== "string") return false;
|
|
669
|
+
const normalizedRange = normalizeDependencyVersion(range);
|
|
670
|
+
if (normalizedRange === null) return false;
|
|
671
|
+
return splitDependencyVersionBranches(normalizedRange).some((branch) => {
|
|
672
|
+
const normalizedBranch = normalizeDependencyVersion(branch);
|
|
673
|
+
return normalizedBranch !== null && getBranchLowestMajor(normalizedBranch) === null && hasUpperBoundComparator(normalizedBranch);
|
|
674
|
+
});
|
|
675
|
+
};
|
|
676
|
+
const peerRangeMinMajor = (range) => {
|
|
677
|
+
if (typeof range !== "string") return null;
|
|
678
|
+
return getLowestDependencyMajor(range);
|
|
679
|
+
};
|
|
680
|
+
const resolveEffectiveReactMajor = (reactVersion, packageJson) => {
|
|
681
|
+
const installedReactMajor = parseReactMajor(reactVersion);
|
|
682
|
+
const peerReactRange = packageJson.peerDependencies?.react;
|
|
683
|
+
if (typeof peerReactRange !== "string") return installedReactMajor;
|
|
684
|
+
const peerFloor = peerRangeMinMajor(peerReactRange);
|
|
685
|
+
if (peerFloor === null) return hasUpperBoundOnlyPeerRange(peerReactRange) ? null : installedReactMajor;
|
|
686
|
+
return installedReactMajor !== null ? Math.min(installedReactMajor, peerFloor) : peerFloor;
|
|
687
|
+
};
|
|
688
|
+
const listWorkspacePackages = (rootDirectory) => {
|
|
689
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
690
|
+
if (!isFile(packageJsonPath)) return [];
|
|
691
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
692
|
+
const patterns = getWorkspacePatterns(rootDirectory, packageJson);
|
|
693
|
+
if (patterns.length === 0) return [];
|
|
694
|
+
const packages = [];
|
|
695
|
+
const seenDirectories = /* @__PURE__ */ new Set();
|
|
696
|
+
const pushIfNew = (workspacePackage) => {
|
|
697
|
+
if (seenDirectories.has(workspacePackage.directory)) return;
|
|
698
|
+
seenDirectories.add(workspacePackage.directory);
|
|
699
|
+
packages.push(workspacePackage);
|
|
700
|
+
};
|
|
701
|
+
if (hasReactDependency(packageJson)) pushIfNew({
|
|
702
|
+
name: packageJson.name ?? path.basename(rootDirectory),
|
|
703
|
+
directory: rootDirectory
|
|
704
|
+
});
|
|
705
|
+
for (const pattern of patterns) {
|
|
706
|
+
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
|
|
707
|
+
for (const workspaceDirectory of directories) {
|
|
708
|
+
const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, "package.json"));
|
|
709
|
+
if (!hasReactDependency(workspacePackageJson)) continue;
|
|
710
|
+
pushIfNew({
|
|
711
|
+
name: workspacePackageJson.name ?? path.basename(workspaceDirectory),
|
|
712
|
+
directory: workspaceDirectory
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return packages;
|
|
717
|
+
};
|
|
718
|
+
const toReactWorkspacePackages = (directories) => {
|
|
719
|
+
const packages = [];
|
|
720
|
+
for (const directory of directories) {
|
|
721
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
722
|
+
if (!isFile(packageJsonPath)) continue;
|
|
723
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
724
|
+
if (!hasReactDependency(packageJson)) continue;
|
|
725
|
+
const name = packageJson.name ?? path.basename(directory);
|
|
726
|
+
packages.push({
|
|
727
|
+
name,
|
|
728
|
+
directory
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
return packages;
|
|
732
|
+
};
|
|
733
|
+
const listManifestWorkspacePackages = (rootDirectory) => {
|
|
734
|
+
if (isFile(path.join(rootDirectory, "package.json"))) return listWorkspacePackages(rootDirectory);
|
|
735
|
+
const patterns = parsePnpmWorkspacePatterns(rootDirectory);
|
|
736
|
+
const nxPatterns = patterns.length > 0 ? [] : getNxWorkspaceDirectories(rootDirectory);
|
|
737
|
+
return toReactWorkspacePackages((patterns.length > 0 ? patterns : nxPatterns).flatMap((pattern) => resolveWorkspaceDirectories(rootDirectory, pattern)));
|
|
738
|
+
};
|
|
739
|
+
const discoverReactSubprojectsByFilesystem = (rootDirectory) => {
|
|
740
|
+
const packages = [];
|
|
741
|
+
const pendingDirectories = [rootDirectory];
|
|
742
|
+
while (pendingDirectories.length > 0) {
|
|
743
|
+
const currentDirectory = pendingDirectories.pop();
|
|
744
|
+
if (!currentDirectory) continue;
|
|
745
|
+
const packageJsonPath = path.join(currentDirectory, "package.json");
|
|
746
|
+
if (isFile(packageJsonPath)) {
|
|
747
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
748
|
+
if (hasReactDependency(packageJson)) {
|
|
749
|
+
const name = packageJson.name ?? path.basename(currentDirectory);
|
|
750
|
+
packages.push({
|
|
751
|
+
name,
|
|
752
|
+
directory: currentDirectory
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true }).toSorted((firstEntry, secondEntry) => firstEntry.name.localeCompare(secondEntry.name));
|
|
757
|
+
for (const entry of entries) {
|
|
758
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
759
|
+
pendingDirectories.push(path.join(currentDirectory, entry.name));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return packages;
|
|
763
|
+
};
|
|
764
|
+
const discoverReactSubprojects = (rootDirectory) => {
|
|
765
|
+
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
|
|
766
|
+
const manifestPackages = listManifestWorkspacePackages(rootDirectory);
|
|
767
|
+
if (manifestPackages.length > 0) return manifestPackages;
|
|
768
|
+
return discoverReactSubprojectsByFilesystem(rootDirectory);
|
|
769
|
+
};
|
|
770
|
+
const cachedProjectInfos = /* @__PURE__ */ new Map();
|
|
771
|
+
const clearProjectCache = () => {
|
|
772
|
+
cachedProjectInfos.clear();
|
|
773
|
+
};
|
|
774
|
+
const discoverProject = (directory) => {
|
|
775
|
+
const cached = cachedProjectInfos.get(directory);
|
|
776
|
+
if (cached !== void 0) return cached;
|
|
777
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
778
|
+
if (!isFile(packageJsonPath)) throw new PackageJsonNotFoundError(directory);
|
|
779
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
780
|
+
let { reactVersion, tailwindVersion, framework } = extractDependencyInfo(packageJson);
|
|
781
|
+
const reactDeclaration = getDependencyDeclaration({
|
|
782
|
+
packageJson,
|
|
783
|
+
packageName: "react",
|
|
784
|
+
sections: [
|
|
785
|
+
"dependencies",
|
|
786
|
+
"peerDependencies",
|
|
787
|
+
"devDependencies"
|
|
788
|
+
]
|
|
789
|
+
});
|
|
790
|
+
const tailwindDeclaration = getDependencyDeclaration({
|
|
791
|
+
packageJson,
|
|
792
|
+
packageName: "tailwindcss",
|
|
793
|
+
sections: [
|
|
794
|
+
"dependencies",
|
|
795
|
+
"devDependencies",
|
|
796
|
+
"peerDependencies"
|
|
797
|
+
]
|
|
798
|
+
});
|
|
799
|
+
if (!reactVersion && reactDeclaration.hasDeclaration) reactVersion = resolveCatalogVersion(packageJson, "react", directory, reactDeclaration.catalogReference);
|
|
800
|
+
if (!tailwindVersion && tailwindDeclaration.hasDeclaration) tailwindVersion = resolveCatalogVersion(packageJson, "tailwindcss", directory, tailwindDeclaration.catalogReference);
|
|
801
|
+
if (!reactVersion || !tailwindVersion) {
|
|
802
|
+
const monorepoRoot = findMonorepoRoot(directory);
|
|
803
|
+
if (monorepoRoot) {
|
|
804
|
+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
|
|
805
|
+
if (isFile(monorepoPackageJsonPath)) {
|
|
806
|
+
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
|
|
807
|
+
if (!reactVersion && reactDeclaration.hasDeclaration) reactVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot, reactDeclaration.catalogReference);
|
|
808
|
+
if (!tailwindVersion && tailwindDeclaration.hasDeclaration) tailwindVersion = resolveCatalogVersion(rootPackageJson, "tailwindcss", monorepoRoot, tailwindDeclaration.catalogReference);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (!reactVersion || framework === "unknown") {
|
|
813
|
+
const workspaceInfo = findReactInWorkspaces(directory, packageJson);
|
|
814
|
+
if (!reactVersion && workspaceInfo.reactVersion) reactVersion = workspaceInfo.reactVersion;
|
|
815
|
+
if (!tailwindVersion && workspaceInfo.tailwindVersion) tailwindVersion = workspaceInfo.tailwindVersion;
|
|
816
|
+
if (framework === "unknown" && workspaceInfo.framework !== "unknown") framework = workspaceInfo.framework;
|
|
817
|
+
}
|
|
818
|
+
if ((!reactVersion || framework === "unknown") && !isMonorepoRoot(directory)) {
|
|
819
|
+
const monorepoInfo = findDependencyInfoFromMonorepoRoot(directory);
|
|
820
|
+
if (!reactVersion) reactVersion = monorepoInfo.reactVersion;
|
|
821
|
+
if (!tailwindVersion) tailwindVersion = monorepoInfo.tailwindVersion;
|
|
822
|
+
if (framework === "unknown") framework = monorepoInfo.framework;
|
|
823
|
+
}
|
|
824
|
+
if (!reactVersion && reactDeclaration.version && !isCatalogReference(reactDeclaration.version)) reactVersion = reactDeclaration.version;
|
|
825
|
+
if (!tailwindVersion && tailwindDeclaration.version && !isCatalogReference(tailwindDeclaration.version)) tailwindVersion = tailwindDeclaration.version;
|
|
826
|
+
const projectName = packageJson.name ?? path.basename(directory);
|
|
827
|
+
const hasTypeScript = fs.existsSync(path.join(directory, "tsconfig.json"));
|
|
828
|
+
const sourceFileCount = countSourceFiles(directory);
|
|
829
|
+
const projectInfo = {
|
|
830
|
+
rootDirectory: directory,
|
|
831
|
+
projectName,
|
|
832
|
+
reactVersion,
|
|
833
|
+
reactMajorVersion: resolveEffectiveReactMajor(reactVersion, packageJson),
|
|
834
|
+
tailwindVersion,
|
|
835
|
+
framework,
|
|
836
|
+
hasTypeScript,
|
|
837
|
+
hasReactCompiler: detectReactCompiler(directory, packageJson),
|
|
838
|
+
hasTanStackQuery: hasTanStackQuery(packageJson),
|
|
839
|
+
sourceFileCount
|
|
840
|
+
};
|
|
841
|
+
cachedProjectInfos.set(directory, projectInfo);
|
|
842
|
+
return projectInfo;
|
|
843
|
+
};
|
|
844
|
+
const parseTailwindMajorMinor = (tailwindVersion) => {
|
|
845
|
+
if (typeof tailwindVersion !== "string") return null;
|
|
846
|
+
const trimmed = tailwindVersion.trim();
|
|
847
|
+
if (trimmed.length === 0) return null;
|
|
848
|
+
const majorMinorMatch = trimmed.match(/(\d+)\.(\d+)/);
|
|
849
|
+
if (majorMinorMatch) {
|
|
850
|
+
const major = Number.parseInt(majorMinorMatch[1], 10);
|
|
851
|
+
const minor = Number.parseInt(majorMinorMatch[2], 10);
|
|
852
|
+
if (!Number.isFinite(major) || major <= 0) return null;
|
|
853
|
+
if (!Number.isFinite(minor) || minor < 0) return null;
|
|
854
|
+
return {
|
|
855
|
+
major,
|
|
856
|
+
minor
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
const majorOnlyMatch = trimmed.match(/(\d+)/);
|
|
860
|
+
if (!majorOnlyMatch) return null;
|
|
861
|
+
const major = Number.parseInt(majorOnlyMatch[1], 10);
|
|
862
|
+
if (!Number.isFinite(major) || major <= 0) return null;
|
|
863
|
+
return {
|
|
864
|
+
major,
|
|
865
|
+
minor: 0
|
|
866
|
+
};
|
|
867
|
+
};
|
|
868
|
+
const isTailwindAtLeast = (detected, required) => {
|
|
869
|
+
if (detected === null) return true;
|
|
870
|
+
if (detected.major !== required.major) return detected.major > required.major;
|
|
871
|
+
return detected.minor >= required.minor;
|
|
872
|
+
};
|
|
873
|
+
//#endregion
|
|
874
|
+
//#region ../core/dist/index.js
|
|
875
|
+
var __create = Object.create;
|
|
876
|
+
var __defProp = Object.defineProperty;
|
|
877
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
878
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
879
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
880
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
881
|
+
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
882
|
+
var __copyProps = (to, from, except, desc) => {
|
|
883
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
884
|
+
key = keys[i];
|
|
885
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
886
|
+
get: ((k) => from[k]).bind(null, key),
|
|
887
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
return to;
|
|
891
|
+
};
|
|
892
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
893
|
+
value: mod,
|
|
894
|
+
enumerable: true
|
|
895
|
+
}) : target, mod));
|
|
896
|
+
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
897
|
+
const compileGlobPattern = (pattern) => {
|
|
898
|
+
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
|
|
899
|
+
let regexSource = "^";
|
|
900
|
+
let characterIndex = 0;
|
|
901
|
+
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
902
|
+
regexSource += "(?:.+/)?";
|
|
903
|
+
characterIndex += 3;
|
|
904
|
+
} else {
|
|
905
|
+
regexSource += ".*";
|
|
906
|
+
characterIndex += 2;
|
|
907
|
+
}
|
|
908
|
+
else if (normalizedPattern[characterIndex] === "*") {
|
|
909
|
+
regexSource += "[^/]*";
|
|
910
|
+
characterIndex++;
|
|
911
|
+
} else if (normalizedPattern[characterIndex] === "?") {
|
|
912
|
+
regexSource += "[^/]";
|
|
913
|
+
characterIndex++;
|
|
914
|
+
} else {
|
|
915
|
+
regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
|
|
916
|
+
characterIndex++;
|
|
917
|
+
}
|
|
918
|
+
regexSource += "$";
|
|
919
|
+
return new RegExp(regexSource);
|
|
920
|
+
};
|
|
921
|
+
const toRelativePath = (filePath, rootDirectory) => {
|
|
922
|
+
const normalizedFilePath = filePath.replace(/\\/g, "/");
|
|
923
|
+
const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
|
|
924
|
+
if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
|
|
925
|
+
return normalizedFilePath.replace(/^\.\//, "");
|
|
926
|
+
};
|
|
927
|
+
const warnConfigField$1 = (message) => {
|
|
928
|
+
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
929
|
+
};
|
|
930
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
931
|
+
const collectStringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
932
|
+
const validateOverrideEntry = (entry, index) => {
|
|
933
|
+
if (!isPlainObject(entry)) {
|
|
934
|
+
warnConfigField$1(`ignore.overrides[${index}] must be an object with { files, rules }; ignoring this entry.`);
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
if (!isStringArray(entry.files)) {
|
|
938
|
+
warnConfigField$1(`ignore.overrides[${index}].files must be an array of strings; ignoring this entry.`);
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
if (entry.rules !== void 0 && !isStringArray(entry.rules)) {
|
|
942
|
+
warnConfigField$1(`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).`);
|
|
943
|
+
return { files: entry.files };
|
|
944
|
+
}
|
|
945
|
+
return entry.rules === void 0 ? { files: entry.files } : {
|
|
946
|
+
files: entry.files,
|
|
947
|
+
rules: entry.rules
|
|
948
|
+
};
|
|
949
|
+
};
|
|
950
|
+
const compileIgnoreOverrides = (userConfig) => {
|
|
951
|
+
const overrides = userConfig?.ignore?.overrides;
|
|
952
|
+
if (overrides === void 0) return [];
|
|
953
|
+
if (!Array.isArray(overrides)) {
|
|
954
|
+
warnConfigField$1(`ignore.overrides must be an array of { files, rules } entries; ignoring.`);
|
|
955
|
+
return [];
|
|
956
|
+
}
|
|
957
|
+
return overrides.flatMap((entry, index) => {
|
|
958
|
+
const validated = validateOverrideEntry(entry, index);
|
|
959
|
+
if (!validated) return [];
|
|
960
|
+
const filePatterns = collectStringList(validated.files).map(compileGlobPattern);
|
|
961
|
+
if (filePatterns.length === 0) return [];
|
|
962
|
+
return [{
|
|
963
|
+
filePatterns,
|
|
964
|
+
ruleIds: new Set(collectStringList(validated.rules))
|
|
965
|
+
}];
|
|
966
|
+
});
|
|
967
|
+
};
|
|
968
|
+
const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) => {
|
|
969
|
+
if (overrides.length === 0) return false;
|
|
970
|
+
const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory);
|
|
971
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
972
|
+
return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
|
|
973
|
+
};
|
|
974
|
+
const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
|
|
975
|
+
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
976
|
+
const FETCH_TIMEOUT_MS = 1e4;
|
|
977
|
+
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
978
|
+
const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
|
|
979
|
+
const OXLINT_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
980
|
+
const estimateArgsLength = (args) => args.reduce((total, argument) => total + argument.length + 1, 0);
|
|
981
|
+
const batchIncludePaths = (baseArgs, includePaths) => {
|
|
982
|
+
const baseArgsLength = estimateArgsLength(baseArgs);
|
|
983
|
+
const batches = [];
|
|
984
|
+
let currentBatch = [];
|
|
985
|
+
let currentBatchLength = baseArgsLength;
|
|
986
|
+
for (const filePath of includePaths) {
|
|
987
|
+
const entryLength = filePath.length + 1;
|
|
988
|
+
const exceedsArgLength = currentBatch.length > 0 && currentBatchLength + entryLength > 24e3;
|
|
989
|
+
const exceedsFileCount = currentBatch.length >= 100;
|
|
990
|
+
if (exceedsArgLength || exceedsFileCount) {
|
|
991
|
+
batches.push(currentBatch);
|
|
992
|
+
currentBatch = [];
|
|
993
|
+
currentBatchLength = baseArgsLength;
|
|
994
|
+
}
|
|
995
|
+
currentBatch.push(filePath);
|
|
996
|
+
currentBatchLength += entryLength;
|
|
997
|
+
}
|
|
998
|
+
if (currentBatch.length > 0) batches.push(currentBatch);
|
|
999
|
+
return batches;
|
|
1000
|
+
};
|
|
1001
|
+
const collectErrorChain = (rootError) => {
|
|
1002
|
+
const errorChain = [];
|
|
1003
|
+
const visitedErrors = /* @__PURE__ */ new Set();
|
|
1004
|
+
let currentError = rootError;
|
|
1005
|
+
while (currentError !== void 0 && !visitedErrors.has(currentError)) {
|
|
1006
|
+
visitedErrors.add(currentError);
|
|
1007
|
+
errorChain.push(currentError);
|
|
1008
|
+
currentError = currentError instanceof Error ? currentError.cause : void 0;
|
|
1009
|
+
}
|
|
1010
|
+
return errorChain;
|
|
1011
|
+
};
|
|
1012
|
+
const formatErrorMessage = (error) => error instanceof Error ? error.message || error.name : String(error);
|
|
1013
|
+
const getErrorChainMessages = (rootError) => collectErrorChain(rootError).map(formatErrorMessage);
|
|
1014
|
+
const safeStringify = (value) => {
|
|
1015
|
+
try {
|
|
1016
|
+
return String(value);
|
|
1017
|
+
} catch {
|
|
1018
|
+
return "Unrepresentable error";
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
const safeGetErrorChain = (error) => {
|
|
1022
|
+
try {
|
|
1023
|
+
return getErrorChainMessages(error);
|
|
1024
|
+
} catch {
|
|
1025
|
+
return [safeStringify(error)];
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
const buildJsonReportError = (input) => {
|
|
1029
|
+
const chain = safeGetErrorChain(input.error);
|
|
1030
|
+
const errorPayload = input.error instanceof Error ? {
|
|
1031
|
+
message: input.error.message || input.error.name || "Error",
|
|
1032
|
+
name: input.error.name || "Error",
|
|
1033
|
+
chain
|
|
1034
|
+
} : {
|
|
1035
|
+
message: safeStringify(input.error),
|
|
1036
|
+
name: "Error",
|
|
1037
|
+
chain
|
|
1038
|
+
};
|
|
1039
|
+
return {
|
|
1040
|
+
schemaVersion: 1,
|
|
1041
|
+
version: input.version,
|
|
1042
|
+
ok: false,
|
|
1043
|
+
directory: input.directory,
|
|
1044
|
+
mode: input.mode ?? "full",
|
|
1045
|
+
diff: null,
|
|
1046
|
+
projects: [],
|
|
1047
|
+
diagnostics: [],
|
|
1048
|
+
summary: {
|
|
1049
|
+
errorCount: 0,
|
|
1050
|
+
warningCount: 0,
|
|
1051
|
+
affectedFileCount: 0,
|
|
1052
|
+
totalDiagnosticCount: 0,
|
|
1053
|
+
score: null,
|
|
1054
|
+
scoreLabel: null
|
|
1055
|
+
},
|
|
1056
|
+
elapsedMilliseconds: input.elapsedMilliseconds,
|
|
1057
|
+
error: errorPayload
|
|
1058
|
+
};
|
|
1059
|
+
};
|
|
1060
|
+
const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
|
|
1061
|
+
let errorCount = 0;
|
|
1062
|
+
let warningCount = 0;
|
|
1063
|
+
const affectedFiles = /* @__PURE__ */ new Set();
|
|
1064
|
+
for (const diagnostic of diagnostics) {
|
|
1065
|
+
if (diagnostic.severity === "error") errorCount++;
|
|
1066
|
+
else warningCount++;
|
|
1067
|
+
affectedFiles.add(diagnostic.filePath);
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
errorCount,
|
|
1071
|
+
warningCount,
|
|
1072
|
+
affectedFileCount: affectedFiles.size,
|
|
1073
|
+
totalDiagnosticCount: diagnostics.length,
|
|
1074
|
+
score: worstScore,
|
|
1075
|
+
scoreLabel: worstScoreLabel
|
|
1076
|
+
};
|
|
1077
|
+
};
|
|
1078
|
+
const toJsonDiff = (diff) => {
|
|
1079
|
+
if (!diff) return null;
|
|
1080
|
+
return {
|
|
1081
|
+
baseBranch: diff.baseBranch,
|
|
1082
|
+
currentBranch: diff.currentBranch,
|
|
1083
|
+
changedFileCount: diff.changedFiles.length,
|
|
1084
|
+
isCurrentChanges: Boolean(diff.isCurrentChanges)
|
|
1085
|
+
};
|
|
1086
|
+
};
|
|
1087
|
+
const findWorstScoredProject = (projects) => {
|
|
1088
|
+
let worst = null;
|
|
1089
|
+
let worstScore = Number.POSITIVE_INFINITY;
|
|
1090
|
+
for (const project of projects) {
|
|
1091
|
+
const score = project.score?.score;
|
|
1092
|
+
if (typeof score !== "number") continue;
|
|
1093
|
+
if (score < worstScore) {
|
|
1094
|
+
worstScore = score;
|
|
1095
|
+
worst = project;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return worst;
|
|
1099
|
+
};
|
|
1100
|
+
const buildJsonReport = (input) => {
|
|
1101
|
+
const projects = input.scans.map(({ directory, result }) => ({
|
|
1102
|
+
directory,
|
|
1103
|
+
project: result.project,
|
|
1104
|
+
diagnostics: result.diagnostics,
|
|
1105
|
+
score: result.score,
|
|
1106
|
+
skippedChecks: result.skippedChecks,
|
|
1107
|
+
...result.skippedCheckReasons ? { skippedCheckReasons: result.skippedCheckReasons } : {},
|
|
1108
|
+
elapsedMilliseconds: result.elapsedMilliseconds
|
|
1109
|
+
}));
|
|
1110
|
+
const flattenedDiagnostics = projects.flatMap((entry) => entry.diagnostics);
|
|
1111
|
+
const worstScoredProject = findWorstScoredProject(projects);
|
|
1112
|
+
const summary = summarizeDiagnostics(flattenedDiagnostics, worstScoredProject?.score?.score ?? null, worstScoredProject?.score?.label ?? null);
|
|
1113
|
+
return {
|
|
1114
|
+
schemaVersion: 1,
|
|
1115
|
+
version: input.version,
|
|
1116
|
+
ok: true,
|
|
1117
|
+
directory: input.directory,
|
|
1118
|
+
mode: input.mode,
|
|
1119
|
+
diff: toJsonDiff(input.diff),
|
|
1120
|
+
projects,
|
|
1121
|
+
diagnostics: flattenedDiagnostics,
|
|
1122
|
+
summary,
|
|
1123
|
+
elapsedMilliseconds: input.totalElapsedMilliseconds,
|
|
1124
|
+
error: null
|
|
1125
|
+
};
|
|
1126
|
+
};
|
|
1127
|
+
const parseScoreResult = (value) => {
|
|
1128
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1129
|
+
if (!("score" in value) || !("label" in value)) return null;
|
|
1130
|
+
const scoreValue = Reflect.get(value, "score");
|
|
1131
|
+
const labelValue = Reflect.get(value, "label");
|
|
1132
|
+
if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
|
|
1133
|
+
return {
|
|
1134
|
+
score: scoreValue,
|
|
1135
|
+
label: labelValue
|
|
1136
|
+
};
|
|
1137
|
+
};
|
|
1138
|
+
const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
|
|
1139
|
+
const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
1140
|
+
const describeFailure = (error) => {
|
|
1141
|
+
if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
|
|
1142
|
+
if (error instanceof Error && error.message) return error.message;
|
|
1143
|
+
return String(error);
|
|
1144
|
+
};
|
|
1145
|
+
const calculateScore = async (diagnostics) => {
|
|
1146
|
+
const controller = new AbortController();
|
|
1147
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
1148
|
+
try {
|
|
1149
|
+
const response = await fetch(SCORE_API_URL, {
|
|
1150
|
+
method: "POST",
|
|
1151
|
+
headers: { "Content-Type": "application/json" },
|
|
1152
|
+
body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
|
|
1153
|
+
signal: controller.signal
|
|
1154
|
+
});
|
|
1155
|
+
if (!response.ok) {
|
|
1156
|
+
console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText}`);
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
return parseScoreResult(await response.json());
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)})`);
|
|
1162
|
+
return null;
|
|
1163
|
+
} finally {
|
|
1164
|
+
clearTimeout(timeoutId);
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
const EXTENDS_LOCAL_PATH_PREFIXES = [
|
|
1168
|
+
"./",
|
|
1169
|
+
"../",
|
|
1170
|
+
"/"
|
|
1171
|
+
];
|
|
1172
|
+
const isLocalPathExtend = (entry) => {
|
|
1173
|
+
for (const prefix of EXTENDS_LOCAL_PATH_PREFIXES) if (entry.startsWith(prefix)) return true;
|
|
1174
|
+
return false;
|
|
1175
|
+
};
|
|
1176
|
+
const stripJsoncComments = (raw) => {
|
|
1177
|
+
let result = "";
|
|
1178
|
+
let cursor = 0;
|
|
1179
|
+
let inString = false;
|
|
1180
|
+
let stringQuote = "";
|
|
1181
|
+
while (cursor < raw.length) {
|
|
1182
|
+
const character = raw[cursor];
|
|
1183
|
+
const nextCharacter = raw[cursor + 1];
|
|
1184
|
+
if (inString) {
|
|
1185
|
+
result += character;
|
|
1186
|
+
if (character === "\\" && cursor + 1 < raw.length) {
|
|
1187
|
+
result += nextCharacter;
|
|
1188
|
+
cursor += 2;
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
if (character === stringQuote) inString = false;
|
|
1192
|
+
cursor += 1;
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
if (character === "\"" || character === "'") {
|
|
1196
|
+
inString = true;
|
|
1197
|
+
stringQuote = character;
|
|
1198
|
+
result += character;
|
|
1199
|
+
cursor += 1;
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
if (character === "/" && nextCharacter === "/") {
|
|
1203
|
+
const lineEndIndex = raw.indexOf("\n", cursor);
|
|
1204
|
+
cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (character === "/" && nextCharacter === "*") {
|
|
1208
|
+
const blockEndIndex = raw.indexOf("*/", cursor + 2);
|
|
1209
|
+
cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
result += character;
|
|
1213
|
+
cursor += 1;
|
|
1214
|
+
}
|
|
1215
|
+
return result;
|
|
1216
|
+
};
|
|
1217
|
+
const parseJsonOrJsonc = (raw) => {
|
|
1218
|
+
try {
|
|
1219
|
+
return JSON.parse(raw);
|
|
1220
|
+
} catch {
|
|
1221
|
+
return JSON.parse(stripJsoncComments(raw));
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
const canOxlintExtendConfig = (configPath) => {
|
|
1225
|
+
if (!configPath.endsWith(".eslintrc.json")) return true;
|
|
1226
|
+
let parsed;
|
|
1227
|
+
try {
|
|
1228
|
+
parsed = parseJsonOrJsonc(fs.readFileSync(configPath, "utf-8"));
|
|
1229
|
+
} catch {
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
if (!isPlainObject(parsed)) return true;
|
|
1233
|
+
const extendsValue = parsed.extends;
|
|
1234
|
+
if (extendsValue === void 0 || extendsValue === null) return true;
|
|
1235
|
+
const extendsEntries = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
|
|
1236
|
+
if (extendsEntries.length === 0) return true;
|
|
1237
|
+
return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
|
|
1238
|
+
};
|
|
1239
|
+
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
|
|
1240
|
+
const REDUCED_MOTION_FILE_GLOBS = [
|
|
1241
|
+
"*.ts",
|
|
1242
|
+
"*.tsx",
|
|
1243
|
+
"*.js",
|
|
1244
|
+
"*.jsx",
|
|
1245
|
+
"*.css",
|
|
1246
|
+
"*.scss"
|
|
1247
|
+
];
|
|
1248
|
+
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
1249
|
+
filePath: "package.json",
|
|
1250
|
+
plugin: "react-doctor",
|
|
1251
|
+
rule: "require-reduced-motion",
|
|
1252
|
+
severity: "error",
|
|
1253
|
+
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
1254
|
+
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
1255
|
+
line: 0,
|
|
1256
|
+
column: 0,
|
|
1257
|
+
category: "Accessibility"
|
|
1258
|
+
};
|
|
1259
|
+
const checkReducedMotion = (rootDirectory) => {
|
|
1260
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
1261
|
+
if (!isFile(packageJsonPath)) return [];
|
|
1262
|
+
let hasMotionLibrary = false;
|
|
1263
|
+
try {
|
|
1264
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
1265
|
+
const allDependencies = {
|
|
1266
|
+
...packageJson.dependencies,
|
|
1267
|
+
...packageJson.devDependencies
|
|
1268
|
+
};
|
|
1269
|
+
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
1270
|
+
} catch {
|
|
1271
|
+
return [];
|
|
1272
|
+
}
|
|
1273
|
+
if (!hasMotionLibrary) return [];
|
|
1274
|
+
const result = spawnSync("git", [
|
|
1275
|
+
"grep",
|
|
1276
|
+
"-ql",
|
|
1277
|
+
"-E",
|
|
1278
|
+
REDUCED_MOTION_GREP_PATTERN,
|
|
1279
|
+
"--",
|
|
1280
|
+
...REDUCED_MOTION_FILE_GLOBS
|
|
1281
|
+
], {
|
|
1282
|
+
cwd: rootDirectory,
|
|
1283
|
+
stdio: [
|
|
1284
|
+
"ignore",
|
|
1285
|
+
"pipe",
|
|
1286
|
+
"pipe"
|
|
1287
|
+
]
|
|
1288
|
+
});
|
|
1289
|
+
if (result.error) return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
1290
|
+
if (result.status === 0) return [];
|
|
1291
|
+
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
1292
|
+
};
|
|
1293
|
+
const LINGUIST_ATTRIBUTE_PATTERN = /^linguist-(?:vendored|generated)(?:=([a-zA-Z0-9]+))?$/i;
|
|
1294
|
+
const FALSY_VALUES = new Set([
|
|
1295
|
+
"false",
|
|
1296
|
+
"0",
|
|
1297
|
+
"off",
|
|
1298
|
+
"no"
|
|
1299
|
+
]);
|
|
1300
|
+
const isTruthyLinguistAttribute = (token) => {
|
|
1301
|
+
const match = LINGUIST_ATTRIBUTE_PATTERN.exec(token);
|
|
1302
|
+
if (!match) return false;
|
|
1303
|
+
if (match[1] === void 0) return true;
|
|
1304
|
+
return !FALSY_VALUES.has(match[1].toLowerCase());
|
|
1305
|
+
};
|
|
1306
|
+
const parseGitattributesLinguistPaths = (filePath) => {
|
|
1307
|
+
let content;
|
|
1308
|
+
try {
|
|
1309
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1310
|
+
} catch {
|
|
1311
|
+
return [];
|
|
1312
|
+
}
|
|
1313
|
+
const paths = [];
|
|
1314
|
+
for (const rawLine of content.split("\n")) {
|
|
1315
|
+
const line = rawLine.trim();
|
|
1316
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
1317
|
+
const tokens = line.split(/\s+/);
|
|
1318
|
+
if (tokens.length < 2) continue;
|
|
1319
|
+
const [pathSpec, ...attributes] = tokens;
|
|
1320
|
+
if (attributes.some(isTruthyLinguistAttribute)) paths.push(pathSpec);
|
|
1321
|
+
}
|
|
1322
|
+
return paths;
|
|
1323
|
+
};
|
|
1324
|
+
var import_picocolors = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
1325
|
+
let p = process || {}, argv = p.argv || [], env = p.env || {};
|
|
1326
|
+
let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
|
|
1327
|
+
let formatter = (open, close, replace = open) => (input) => {
|
|
1328
|
+
let string = "" + input, index = string.indexOf(close, open.length);
|
|
1329
|
+
return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
|
|
1330
|
+
};
|
|
1331
|
+
let replaceClose = (string, close, replace, index) => {
|
|
1332
|
+
let result = "", cursor = 0;
|
|
1333
|
+
do {
|
|
1334
|
+
result += string.substring(cursor, index) + replace;
|
|
1335
|
+
cursor = index + close.length;
|
|
1336
|
+
index = string.indexOf(close, cursor);
|
|
1337
|
+
} while (~index);
|
|
1338
|
+
return result + string.substring(cursor);
|
|
1339
|
+
};
|
|
1340
|
+
let createColors = (enabled = isColorSupported) => {
|
|
1341
|
+
let f = enabled ? formatter : () => String;
|
|
1342
|
+
return {
|
|
1343
|
+
isColorSupported: enabled,
|
|
1344
|
+
reset: f("\x1B[0m", "\x1B[0m"),
|
|
1345
|
+
bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
|
|
1346
|
+
dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
|
|
1347
|
+
italic: f("\x1B[3m", "\x1B[23m"),
|
|
1348
|
+
underline: f("\x1B[4m", "\x1B[24m"),
|
|
1349
|
+
inverse: f("\x1B[7m", "\x1B[27m"),
|
|
1350
|
+
hidden: f("\x1B[8m", "\x1B[28m"),
|
|
1351
|
+
strikethrough: f("\x1B[9m", "\x1B[29m"),
|
|
1352
|
+
black: f("\x1B[30m", "\x1B[39m"),
|
|
1353
|
+
red: f("\x1B[31m", "\x1B[39m"),
|
|
1354
|
+
green: f("\x1B[32m", "\x1B[39m"),
|
|
1355
|
+
yellow: f("\x1B[33m", "\x1B[39m"),
|
|
1356
|
+
blue: f("\x1B[34m", "\x1B[39m"),
|
|
1357
|
+
magenta: f("\x1B[35m", "\x1B[39m"),
|
|
1358
|
+
cyan: f("\x1B[36m", "\x1B[39m"),
|
|
1359
|
+
white: f("\x1B[37m", "\x1B[39m"),
|
|
1360
|
+
gray: f("\x1B[90m", "\x1B[39m"),
|
|
1361
|
+
bgBlack: f("\x1B[40m", "\x1B[49m"),
|
|
1362
|
+
bgRed: f("\x1B[41m", "\x1B[49m"),
|
|
1363
|
+
bgGreen: f("\x1B[42m", "\x1B[49m"),
|
|
1364
|
+
bgYellow: f("\x1B[43m", "\x1B[49m"),
|
|
1365
|
+
bgBlue: f("\x1B[44m", "\x1B[49m"),
|
|
1366
|
+
bgMagenta: f("\x1B[45m", "\x1B[49m"),
|
|
1367
|
+
bgCyan: f("\x1B[46m", "\x1B[49m"),
|
|
1368
|
+
bgWhite: f("\x1B[47m", "\x1B[49m"),
|
|
1369
|
+
blackBright: f("\x1B[90m", "\x1B[39m"),
|
|
1370
|
+
redBright: f("\x1B[91m", "\x1B[39m"),
|
|
1371
|
+
greenBright: f("\x1B[92m", "\x1B[39m"),
|
|
1372
|
+
yellowBright: f("\x1B[93m", "\x1B[39m"),
|
|
1373
|
+
blueBright: f("\x1B[94m", "\x1B[39m"),
|
|
1374
|
+
magentaBright: f("\x1B[95m", "\x1B[39m"),
|
|
1375
|
+
cyanBright: f("\x1B[96m", "\x1B[39m"),
|
|
1376
|
+
whiteBright: f("\x1B[97m", "\x1B[39m"),
|
|
1377
|
+
bgBlackBright: f("\x1B[100m", "\x1B[49m"),
|
|
1378
|
+
bgRedBright: f("\x1B[101m", "\x1B[49m"),
|
|
1379
|
+
bgGreenBright: f("\x1B[102m", "\x1B[49m"),
|
|
1380
|
+
bgYellowBright: f("\x1B[103m", "\x1B[49m"),
|
|
1381
|
+
bgBlueBright: f("\x1B[104m", "\x1B[49m"),
|
|
1382
|
+
bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
|
|
1383
|
+
bgCyanBright: f("\x1B[106m", "\x1B[49m"),
|
|
1384
|
+
bgWhiteBright: f("\x1B[107m", "\x1B[49m")
|
|
1385
|
+
};
|
|
1386
|
+
};
|
|
1387
|
+
module.exports = createColors();
|
|
1388
|
+
module.exports.createColors = createColors;
|
|
1389
|
+
})))(), 1);
|
|
1390
|
+
const highlighter = {
|
|
1391
|
+
error: import_picocolors.default.red,
|
|
1392
|
+
warn: import_picocolors.default.yellow,
|
|
1393
|
+
info: import_picocolors.default.cyan,
|
|
1394
|
+
success: import_picocolors.default.green,
|
|
1395
|
+
dim: import_picocolors.default.dim,
|
|
1396
|
+
gray: import_picocolors.default.gray,
|
|
1397
|
+
bold: import_picocolors.default.bold
|
|
1398
|
+
};
|
|
1399
|
+
const logger = {
|
|
1400
|
+
error(...args) {
|
|
1401
|
+
console.error(highlighter.error(args.join(" ")));
|
|
1402
|
+
},
|
|
1403
|
+
warn(...args) {
|
|
1404
|
+
console.warn(highlighter.warn(args.join(" ")));
|
|
1405
|
+
},
|
|
1406
|
+
info(...args) {
|
|
1407
|
+
console.log(highlighter.info(args.join(" ")));
|
|
1408
|
+
},
|
|
1409
|
+
success(...args) {
|
|
1410
|
+
console.log(highlighter.success(args.join(" ")));
|
|
1411
|
+
},
|
|
1412
|
+
dim(...args) {
|
|
1413
|
+
console.log(highlighter.dim(args.join(" ")));
|
|
1414
|
+
},
|
|
1415
|
+
log(...args) {
|
|
1416
|
+
console.log(args.join(" "));
|
|
1417
|
+
},
|
|
1418
|
+
break() {
|
|
1419
|
+
console.log("");
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
const stripGitignoreEscape = (pattern) => {
|
|
1423
|
+
if (pattern.startsWith("\\#") || pattern.startsWith("\\!")) return pattern.slice(1);
|
|
1424
|
+
return pattern;
|
|
1425
|
+
};
|
|
1426
|
+
const readIgnoreFile = (filePath) => {
|
|
1427
|
+
let content;
|
|
1428
|
+
try {
|
|
1429
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
const errnoCode = error?.code;
|
|
1432
|
+
if (errnoCode && errnoCode !== "ENOENT") logger.warn(`Could not read ignore file ${filePath}: ${errnoCode}`);
|
|
1433
|
+
return [];
|
|
1434
|
+
}
|
|
1435
|
+
const patterns = [];
|
|
1436
|
+
for (const line of content.split("\n")) {
|
|
1437
|
+
const trimmed = line.trim();
|
|
1438
|
+
if (trimmed.length === 0) continue;
|
|
1439
|
+
if (trimmed.startsWith("#")) continue;
|
|
1440
|
+
patterns.push(stripGitignoreEscape(trimmed));
|
|
1441
|
+
}
|
|
1442
|
+
return patterns;
|
|
1443
|
+
};
|
|
1444
|
+
const IGNORE_FILENAMES = [
|
|
1445
|
+
".eslintignore",
|
|
1446
|
+
".oxlintignore",
|
|
1447
|
+
".prettierignore"
|
|
1448
|
+
];
|
|
1449
|
+
const cachedPatternsByRoot = /* @__PURE__ */ new Map();
|
|
1450
|
+
const clearIgnorePatternsCache = () => {
|
|
1451
|
+
cachedPatternsByRoot.clear();
|
|
1452
|
+
};
|
|
1453
|
+
const computeIgnorePatterns = (rootDirectory) => {
|
|
1454
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1455
|
+
const patterns = [];
|
|
1456
|
+
const addPattern = (pattern) => {
|
|
1457
|
+
if (seen.has(pattern)) return;
|
|
1458
|
+
seen.add(pattern);
|
|
1459
|
+
patterns.push(pattern);
|
|
1460
|
+
};
|
|
1461
|
+
for (const filename of IGNORE_FILENAMES) for (const pattern of readIgnoreFile(path.join(rootDirectory, filename))) addPattern(pattern);
|
|
1462
|
+
for (const linguistPath of parseGitattributesLinguistPaths(path.join(rootDirectory, ".gitattributes"))) addPattern(linguistPath);
|
|
1463
|
+
return patterns;
|
|
1464
|
+
};
|
|
1465
|
+
const collectIgnorePatterns = (rootDirectory) => {
|
|
1466
|
+
const cached = cachedPatternsByRoot.get(rootDirectory);
|
|
1467
|
+
if (cached !== void 0) return cached;
|
|
1468
|
+
const patterns = computeIgnorePatterns(rootDirectory);
|
|
1469
|
+
cachedPatternsByRoot.set(rootDirectory, patterns);
|
|
1470
|
+
return patterns;
|
|
1471
|
+
};
|
|
1472
|
+
const createNodeReadFileLinesSync = (rootDirectory) => {
|
|
1473
|
+
return (filePath) => {
|
|
1474
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
|
|
1475
|
+
try {
|
|
1476
|
+
return fs.readFileSync(absolutePath, "utf-8").split("\n");
|
|
1477
|
+
} catch {
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
};
|
|
1482
|
+
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
1483
|
+
const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
|
|
1484
|
+
const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
|
|
1485
|
+
let stringDelimiter = null;
|
|
1486
|
+
for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
|
|
1487
|
+
const character = line[charIndex];
|
|
1488
|
+
if (stringDelimiter !== null) {
|
|
1489
|
+
if (character === "\\") {
|
|
1490
|
+
charIndex++;
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
if (character === stringDelimiter) stringDelimiter = null;
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
1497
|
+
stringDelimiter = character;
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
if (character === "/" && line[charIndex + 1] === "/") return true;
|
|
1501
|
+
}
|
|
1502
|
+
return false;
|
|
1503
|
+
};
|
|
1504
|
+
const findOpenerTagOnLine = (line) => {
|
|
1505
|
+
for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
|
|
1506
|
+
if (match.index === void 0) continue;
|
|
1507
|
+
if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
|
|
1508
|
+
}
|
|
1509
|
+
return null;
|
|
1510
|
+
};
|
|
1511
|
+
const findJsxOpenerSpan = (lines, openerLineIndex) => {
|
|
1512
|
+
const openerLine = lines[openerLineIndex];
|
|
1513
|
+
if (openerLine === void 0) return null;
|
|
1514
|
+
const opener = findOpenerTagOnLine(openerLine);
|
|
1515
|
+
if (!opener) return null;
|
|
1516
|
+
const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
|
|
1517
|
+
let braceDepth = 0;
|
|
1518
|
+
let innerAngleDepth = 0;
|
|
1519
|
+
let stringDelimiter = null;
|
|
1520
|
+
for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
|
|
1521
|
+
const currentLine = lines[lineIndex];
|
|
1522
|
+
const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
|
|
1523
|
+
for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
|
|
1524
|
+
const character = currentLine[charIndex];
|
|
1525
|
+
if (stringDelimiter !== null) {
|
|
1526
|
+
if (character === "\\") {
|
|
1527
|
+
charIndex++;
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (character === stringDelimiter) stringDelimiter = null;
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
1534
|
+
stringDelimiter = character;
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
1537
|
+
if (character === "{") {
|
|
1538
|
+
braceDepth++;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
if (character === "}") {
|
|
1542
|
+
braceDepth--;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
if (braceDepth !== 0) continue;
|
|
1546
|
+
if (character === "<") {
|
|
1547
|
+
const followCharacter = currentLine[charIndex + 1];
|
|
1548
|
+
if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
if (character !== ">") continue;
|
|
1552
|
+
const previousCharacter = currentLine[charIndex - 1];
|
|
1553
|
+
const nextCharacter = currentLine[charIndex + 1];
|
|
1554
|
+
if (previousCharacter === "=" || nextCharacter === "=") continue;
|
|
1555
|
+
if (innerAngleDepth > 0) {
|
|
1556
|
+
innerAngleDepth--;
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
return lineIndex;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
return null;
|
|
1563
|
+
};
|
|
1564
|
+
const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
|
|
1565
|
+
for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
|
|
1566
|
+
const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
|
|
1567
|
+
if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
|
|
1568
|
+
}
|
|
1569
|
+
return null;
|
|
1570
|
+
};
|
|
1571
|
+
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1572
|
+
const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
1573
|
+
const collected = [];
|
|
1574
|
+
let isStillInChain = true;
|
|
1575
|
+
for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
|
|
1576
|
+
const candidateLine = lines[candidateIndex];
|
|
1577
|
+
if (candidateLine === void 0) break;
|
|
1578
|
+
const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
|
|
1579
|
+
if (match) {
|
|
1580
|
+
collected.push({
|
|
1581
|
+
commentLineIndex: candidateIndex,
|
|
1582
|
+
ruleList: match[1],
|
|
1583
|
+
isInChain: isStillInChain
|
|
1584
|
+
});
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
isStillInChain = false;
|
|
1588
|
+
}
|
|
1589
|
+
return collected;
|
|
1590
|
+
};
|
|
1591
|
+
const stripDescriptionTail = (ruleList) => {
|
|
1592
|
+
const descriptionMatch = ruleList.match(/(?:^|\s)--\s/);
|
|
1593
|
+
if (!descriptionMatch || descriptionMatch.index === void 0) return ruleList;
|
|
1594
|
+
return ruleList.slice(0, descriptionMatch.index);
|
|
1595
|
+
};
|
|
1596
|
+
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
1597
|
+
const trimmed = ruleList?.trim();
|
|
1598
|
+
if (!trimmed) return true;
|
|
1599
|
+
const ruleSection = stripDescriptionTail(trimmed).trim();
|
|
1600
|
+
if (!ruleSection) return true;
|
|
1601
|
+
return ruleSection.split(/[,\s]+/).some((token) => token.trim() === ruleId);
|
|
1602
|
+
};
|
|
1603
|
+
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([^\r\n]*?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1604
|
+
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
1605
|
+
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1606
|
+
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
1607
|
+
const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1608
|
+
const buildAdjacentMismatchHint = (comment, ruleId) => {
|
|
1609
|
+
const ruleListText = comment.ruleList?.trim() ?? "";
|
|
1610
|
+
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}`;
|
|
1611
|
+
};
|
|
1612
|
+
const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
|
|
1613
|
+
const commentLineNumber = comment.commentLineIndex + 1;
|
|
1614
|
+
const diagnosticLineNumber = diagnosticLineIndex + 1;
|
|
1615
|
+
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.`;
|
|
1616
|
+
};
|
|
1617
|
+
const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
|
|
1618
|
+
for (const comments of commentsByAnchor) {
|
|
1619
|
+
const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
|
|
1620
|
+
if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
|
|
1621
|
+
const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
|
|
1622
|
+
if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
|
|
1623
|
+
}
|
|
1624
|
+
return null;
|
|
1625
|
+
};
|
|
1626
|
+
const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
|
|
1627
|
+
const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
|
|
1628
|
+
if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
|
|
1629
|
+
isSuppressed: true,
|
|
1630
|
+
nearMissHint: null
|
|
1631
|
+
};
|
|
1632
|
+
const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
|
|
1633
|
+
if (hasChainSuppressor(directComments, ruleId)) return {
|
|
1634
|
+
isSuppressed: true,
|
|
1635
|
+
nearMissHint: null
|
|
1636
|
+
};
|
|
1637
|
+
const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
|
|
1638
|
+
const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
|
|
1639
|
+
if (hasChainSuppressor(openerComments, ruleId)) return {
|
|
1640
|
+
isSuppressed: true,
|
|
1641
|
+
nearMissHint: null
|
|
1642
|
+
};
|
|
1643
|
+
return {
|
|
1644
|
+
isSuppressed: false,
|
|
1645
|
+
nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
|
|
1646
|
+
};
|
|
1647
|
+
};
|
|
1648
|
+
const compileIgnoredFilePatterns = (userConfig) => {
|
|
1649
|
+
const files = userConfig?.ignore?.files;
|
|
1650
|
+
if (!Array.isArray(files)) return [];
|
|
1651
|
+
return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
|
|
1652
|
+
};
|
|
1653
|
+
const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
|
|
1654
|
+
if (patterns.length === 0) return false;
|
|
1655
|
+
const relativePath = toRelativePath(filePath, rootDirectory);
|
|
1656
|
+
return patterns.some((pattern) => pattern.test(relativePath));
|
|
1657
|
+
};
|
|
1658
|
+
const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
|
|
1659
|
+
const JSX_CHILD_OPEN_PATTERN = /<[A-Za-z]/;
|
|
1660
|
+
const escapeRegExpSpecials = (rawText) => rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1661
|
+
const resolveCandidateReadPath = (rootDirectory, filePath) => {
|
|
1662
|
+
const normalizedFile = filePath.replace(/\\/g, "/");
|
|
1663
|
+
if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
|
|
1664
|
+
return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
|
|
1665
|
+
};
|
|
1666
|
+
const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
|
|
1667
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1668
|
+
return (filePath) => {
|
|
1669
|
+
const cached = cache.get(filePath);
|
|
1670
|
+
if (cached !== void 0) return cached;
|
|
1671
|
+
const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
|
|
1672
|
+
cache.set(filePath, lines);
|
|
1673
|
+
return lines;
|
|
1674
|
+
};
|
|
1675
|
+
};
|
|
1676
|
+
const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
|
|
1677
|
+
for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
|
|
1678
|
+
const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
|
|
1679
|
+
if (!match) continue;
|
|
1680
|
+
const fullTagName = match[1];
|
|
1681
|
+
const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
|
|
1682
|
+
return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
|
|
1683
|
+
}
|
|
1684
|
+
return false;
|
|
1685
|
+
};
|
|
1686
|
+
const findOpenerAtOrAbove = (lines, upperBoundLineIndex) => {
|
|
1687
|
+
for (let lineIndex = upperBoundLineIndex; lineIndex >= 0; lineIndex--) {
|
|
1688
|
+
const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
|
|
1689
|
+
if (!match) continue;
|
|
1690
|
+
const fullName = match[1];
|
|
1691
|
+
return {
|
|
1692
|
+
fullName,
|
|
1693
|
+
leafName: fullName.includes(".") ? fullName.split(".").at(-1) ?? fullName : fullName,
|
|
1694
|
+
lineIndex
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
return null;
|
|
1698
|
+
};
|
|
1699
|
+
const resolveJsxRange = (lines, opener) => {
|
|
1700
|
+
const closingPattern = new RegExp(`</(?:${escapeRegExpSpecials(opener.fullName)}|${escapeRegExpSpecials(opener.leafName)})\\s*>`);
|
|
1701
|
+
let closerLineIndex = -1;
|
|
1702
|
+
let closerColumn = -1;
|
|
1703
|
+
for (let lineIndex = opener.lineIndex; lineIndex < lines.length; lineIndex++) {
|
|
1704
|
+
const match = closingPattern.exec(lines[lineIndex]);
|
|
1705
|
+
if (!match) continue;
|
|
1706
|
+
closerLineIndex = lineIndex;
|
|
1707
|
+
closerColumn = match.index;
|
|
1708
|
+
break;
|
|
1709
|
+
}
|
|
1710
|
+
if (closerLineIndex < 0) return null;
|
|
1711
|
+
const openerLine = lines[opener.lineIndex];
|
|
1712
|
+
const tagStartIndex = openerLine.indexOf(`<${opener.fullName}`);
|
|
1713
|
+
if (tagStartIndex < 0) return null;
|
|
1714
|
+
const openerEndIndex = openerLine.indexOf(">", tagStartIndex);
|
|
1715
|
+
let bodyText;
|
|
1716
|
+
if (opener.lineIndex === closerLineIndex) {
|
|
1717
|
+
if (openerEndIndex < 0 || openerEndIndex >= closerColumn) return null;
|
|
1718
|
+
bodyText = openerLine.slice(openerEndIndex + 1, closerColumn);
|
|
1719
|
+
} else {
|
|
1720
|
+
const segments = [];
|
|
1721
|
+
if (openerEndIndex >= 0) segments.push(openerLine.slice(openerEndIndex + 1));
|
|
1722
|
+
for (let lineIndex = opener.lineIndex + 1; lineIndex < closerLineIndex; lineIndex++) segments.push(lines[lineIndex]);
|
|
1723
|
+
segments.push(lines[closerLineIndex].slice(0, closerColumn));
|
|
1724
|
+
bodyText = segments.join("\n");
|
|
1725
|
+
}
|
|
1726
|
+
return {
|
|
1727
|
+
closerLineIndex,
|
|
1728
|
+
closerColumn,
|
|
1729
|
+
bodyText
|
|
1730
|
+
};
|
|
1731
|
+
};
|
|
1732
|
+
const isInsideStringOnlyWrapper = (lines, diagnosticLine, diagnosticColumn, wrapperNames) => {
|
|
1733
|
+
const diagnosticLineIndex = diagnosticLine - 1;
|
|
1734
|
+
const diagnosticColumnIndex = Math.max(0, diagnosticColumn - 1);
|
|
1735
|
+
let upperBoundLineIndex = diagnosticLineIndex;
|
|
1736
|
+
while (upperBoundLineIndex >= 0) {
|
|
1737
|
+
const opener = findOpenerAtOrAbove(lines, upperBoundLineIndex);
|
|
1738
|
+
if (!opener) return false;
|
|
1739
|
+
const range = resolveJsxRange(lines, opener);
|
|
1740
|
+
if (range === null) {
|
|
1741
|
+
upperBoundLineIndex = opener.lineIndex - 1;
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
if (range.closerLineIndex < diagnosticLineIndex || range.closerLineIndex === diagnosticLineIndex && range.closerColumn <= diagnosticColumnIndex) {
|
|
1745
|
+
upperBoundLineIndex = opener.lineIndex - 1;
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
if (!wrapperNames.has(opener.fullName) && !wrapperNames.has(opener.leafName)) return false;
|
|
1749
|
+
return !JSX_CHILD_OPEN_PATTERN.test(range.bodyText);
|
|
1750
|
+
}
|
|
1751
|
+
return false;
|
|
1752
|
+
};
|
|
1753
|
+
const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
|
|
1754
|
+
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
|
|
1755
|
+
const ignoredFilePatterns = compileIgnoredFilePatterns(config);
|
|
1756
|
+
const compiledOverrides = compileIgnoreOverrides(config);
|
|
1757
|
+
const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
|
|
1758
|
+
const hasTextComponents = textComponentNames.size > 0;
|
|
1759
|
+
const rawTextWrapperComponentNames = new Set(Array.isArray(config.rawTextWrapperComponents) ? config.rawTextWrapperComponents.filter((name) => typeof name === "string") : []);
|
|
1760
|
+
const hasRawTextWrappers = rawTextWrapperComponentNames.size > 0;
|
|
1761
|
+
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
1762
|
+
return diagnostics.filter((diagnostic) => {
|
|
1763
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1764
|
+
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
1765
|
+
if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
|
|
1766
|
+
if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
|
|
1767
|
+
if ((hasTextComponents || hasRawTextWrappers) && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
|
|
1768
|
+
const lines = getFileLines(diagnostic.filePath);
|
|
1769
|
+
if (lines) {
|
|
1770
|
+
if (hasTextComponents && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
|
|
1771
|
+
if (hasRawTextWrappers && isInsideStringOnlyWrapper(lines, diagnostic.line, diagnostic.column, rawTextWrapperComponentNames)) return false;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
return true;
|
|
1775
|
+
});
|
|
1776
|
+
};
|
|
1777
|
+
const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
|
|
1778
|
+
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
1779
|
+
return diagnostics.flatMap((diagnostic) => {
|
|
1780
|
+
if (diagnostic.line <= 0) return [diagnostic];
|
|
1781
|
+
const lines = getFileLines(diagnostic.filePath);
|
|
1782
|
+
if (!lines) return [diagnostic];
|
|
1783
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1784
|
+
const evaluation = evaluateSuppression(lines, diagnostic.line - 1, ruleIdentifier);
|
|
1785
|
+
if (evaluation.isSuppressed) return [];
|
|
1786
|
+
return evaluation.nearMissHint ? [{
|
|
1787
|
+
...diagnostic,
|
|
1788
|
+
suppressionHint: evaluation.nearMissHint
|
|
1789
|
+
}] : [diagnostic];
|
|
1790
|
+
});
|
|
1791
|
+
};
|
|
1792
|
+
const TEST_FILE_PATH_PATTERN = /(?:^|\/)(?:__tests__|__test__|tests|test|__mocks__|cypress|e2e|playwright)\/|\.(?:test|spec|stories|story|fixture|fixtures)\.(?:[cm]?[jt]sx?)$/;
|
|
1793
|
+
const isTestFilePath = (relativePath) => {
|
|
1794
|
+
if (relativePath.length === 0) return false;
|
|
1795
|
+
const forwardSlashed = relativePath.replaceAll("\\", "/");
|
|
1796
|
+
return TEST_FILE_PATH_PATTERN.test(forwardSlashed);
|
|
1797
|
+
};
|
|
1798
|
+
const testFileResultCache = /* @__PURE__ */ new Map();
|
|
1799
|
+
const clearAutoSuppressionCaches = () => {
|
|
1800
|
+
testFileResultCache.clear();
|
|
1801
|
+
};
|
|
1802
|
+
const shouldAutoSuppress = (diagnostic) => {
|
|
1803
|
+
const filePath = diagnostic.filePath;
|
|
1804
|
+
if ((diagnostic.plugin === "react-doctor" ? reactDoctorPlugin.rules[diagnostic.rule] : null)?.tags?.includes("test-noise")) {
|
|
1805
|
+
let isTest = testFileResultCache.get(filePath);
|
|
1806
|
+
if (isTest === void 0) {
|
|
1807
|
+
isTest = isTestFilePath(filePath);
|
|
1808
|
+
testFileResultCache.set(filePath, isTest);
|
|
1809
|
+
}
|
|
1810
|
+
if (isTest) return true;
|
|
1811
|
+
}
|
|
1812
|
+
return false;
|
|
1813
|
+
};
|
|
1814
|
+
const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync, options = {}) => {
|
|
1815
|
+
const autoFiltered = mergedDiagnostics.filter((diagnostic) => !shouldAutoSuppress(diagnostic));
|
|
1816
|
+
const filtered = userConfig ? filterIgnoredDiagnostics(autoFiltered, userConfig, directory, readFileLinesSync) : autoFiltered;
|
|
1817
|
+
if (options.respectInlineDisables === false) return filtered;
|
|
1818
|
+
return filterInlineSuppressions(filtered, directory, readFileLinesSync);
|
|
1819
|
+
};
|
|
1820
|
+
const combineDiagnostics = (input) => {
|
|
1821
|
+
const { lintDiagnostics, directory, isDiffMode, userConfig, readFileLinesSync = createNodeReadFileLinesSync(directory), includeEnvironmentChecks = true, respectInlineDisables } = input;
|
|
1822
|
+
const extraDiagnostics = isDiffMode || !includeEnvironmentChecks ? [] : checkReducedMotion(directory);
|
|
1823
|
+
return mergeAndFilterDiagnostics([...lintDiagnostics, ...extraDiagnostics], directory, userConfig, readFileLinesSync, { respectInlineDisables });
|
|
1824
|
+
};
|
|
1825
|
+
const findFirstLintConfigInDirectory = (directory) => {
|
|
1826
|
+
for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
|
|
1827
|
+
const candidatePath = path.join(directory, filename);
|
|
1828
|
+
if (isFile(candidatePath)) return candidatePath;
|
|
1829
|
+
}
|
|
1830
|
+
return null;
|
|
1831
|
+
};
|
|
1832
|
+
const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1833
|
+
const detectUserLintConfigPaths = (rootDirectory) => {
|
|
1834
|
+
const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
|
|
1835
|
+
if (directLintConfig) return [directLintConfig];
|
|
1836
|
+
if (isProjectBoundary$1(rootDirectory)) return [];
|
|
1837
|
+
let ancestorDirectory = path.dirname(rootDirectory);
|
|
1838
|
+
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
1839
|
+
const ancestorLintConfig = findFirstLintConfigInDirectory(ancestorDirectory);
|
|
1840
|
+
if (ancestorLintConfig) return [ancestorLintConfig];
|
|
1841
|
+
if (isProjectBoundary$1(ancestorDirectory)) return [];
|
|
1842
|
+
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
1843
|
+
}
|
|
1844
|
+
return [];
|
|
1845
|
+
};
|
|
1846
|
+
const runGit = (cwd, args) => {
|
|
1847
|
+
const result = spawnSync("git", args, {
|
|
1848
|
+
cwd,
|
|
1849
|
+
stdio: [
|
|
1850
|
+
"ignore",
|
|
1851
|
+
"pipe",
|
|
1852
|
+
"pipe"
|
|
1853
|
+
],
|
|
1854
|
+
encoding: "utf-8"
|
|
1855
|
+
});
|
|
1856
|
+
if (result.error || result.status !== 0) return null;
|
|
1857
|
+
return result.stdout.toString().trim();
|
|
1858
|
+
};
|
|
1859
|
+
const getCurrentBranch = (directory) => {
|
|
1860
|
+
const branch = runGit(directory, [
|
|
1861
|
+
"rev-parse",
|
|
1862
|
+
"--abbrev-ref",
|
|
1863
|
+
"HEAD"
|
|
1864
|
+
]);
|
|
1865
|
+
if (!branch) return null;
|
|
1866
|
+
return branch === "HEAD" ? null : branch;
|
|
1867
|
+
};
|
|
1868
|
+
const detectDefaultBranch = (directory) => {
|
|
1869
|
+
const reference = runGit(directory, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
1870
|
+
if (reference) return reference.replace("refs/remotes/origin/", "");
|
|
1871
|
+
const output = runGit(directory, [
|
|
1872
|
+
"for-each-ref",
|
|
1873
|
+
"--format=%(refname:short)",
|
|
1874
|
+
...DEFAULT_BRANCH_CANDIDATES.map((candidate) => `refs/heads/${candidate}`)
|
|
1875
|
+
]);
|
|
1876
|
+
if (output) {
|
|
1877
|
+
const firstLine = output.split("\n")[0]?.trim();
|
|
1878
|
+
if (firstLine) return firstLine;
|
|
1879
|
+
}
|
|
1880
|
+
return null;
|
|
1881
|
+
};
|
|
1882
|
+
const branchExists = (directory, branch) => {
|
|
1883
|
+
const result = spawnSync("git", [
|
|
1884
|
+
"rev-parse",
|
|
1885
|
+
"--verify",
|
|
1886
|
+
branch
|
|
1887
|
+
], {
|
|
1888
|
+
cwd: directory,
|
|
1889
|
+
stdio: [
|
|
1890
|
+
"ignore",
|
|
1891
|
+
"pipe",
|
|
1892
|
+
"pipe"
|
|
1893
|
+
]
|
|
1894
|
+
});
|
|
1895
|
+
return !result.error && result.status === 0;
|
|
1896
|
+
};
|
|
1897
|
+
const runGitNullSeparated = (cwd, args) => {
|
|
1898
|
+
const result = spawnSync("git", args, {
|
|
1899
|
+
cwd,
|
|
1900
|
+
stdio: [
|
|
1901
|
+
"ignore",
|
|
1902
|
+
"pipe",
|
|
1903
|
+
"pipe"
|
|
1904
|
+
],
|
|
1905
|
+
encoding: "utf-8"
|
|
1906
|
+
});
|
|
1907
|
+
if (result.error || result.status !== 0) return null;
|
|
1908
|
+
return result.stdout.toString().split("\0").filter((filePath) => filePath.length > 0);
|
|
1909
|
+
};
|
|
1910
|
+
const getChangedFilesSinceBranch = (directory, baseBranch) => {
|
|
1911
|
+
const mergeBase = runGit(directory, [
|
|
1912
|
+
"merge-base",
|
|
1913
|
+
baseBranch,
|
|
1914
|
+
"HEAD"
|
|
1915
|
+
]);
|
|
1916
|
+
if (mergeBase === null) return null;
|
|
1917
|
+
return runGitNullSeparated(directory, [
|
|
1918
|
+
"diff",
|
|
1919
|
+
"-z",
|
|
1920
|
+
"--name-only",
|
|
1921
|
+
"--diff-filter=ACMR",
|
|
1922
|
+
"--relative",
|
|
1923
|
+
mergeBase
|
|
1924
|
+
]);
|
|
1925
|
+
};
|
|
1926
|
+
const getUncommittedChangedFiles = (directory) => {
|
|
1927
|
+
return runGitNullSeparated(directory, [
|
|
1928
|
+
"diff",
|
|
1929
|
+
"-z",
|
|
1930
|
+
"--name-only",
|
|
1931
|
+
"--diff-filter=ACMR",
|
|
1932
|
+
"--relative",
|
|
1933
|
+
"HEAD"
|
|
1934
|
+
]) ?? [];
|
|
1935
|
+
};
|
|
1936
|
+
const getDiffInfo = (directory, explicitBaseBranch) => {
|
|
1937
|
+
if (explicitBaseBranch !== void 0 && explicitBaseBranch.trim().length === 0) throw new Error("Diff base branch cannot be empty.");
|
|
1938
|
+
const currentBranch = getCurrentBranch(directory);
|
|
1939
|
+
if (!currentBranch) return null;
|
|
1940
|
+
const baseBranch = explicitBaseBranch ?? detectDefaultBranch(directory);
|
|
1941
|
+
if (!baseBranch) return null;
|
|
1942
|
+
if (explicitBaseBranch && !branchExists(directory, explicitBaseBranch)) throw new Error(`Diff base branch "${explicitBaseBranch}" does not exist (run \`git fetch\` to update remote refs).`);
|
|
1943
|
+
if (currentBranch === baseBranch) {
|
|
1944
|
+
const uncommittedFiles = getUncommittedChangedFiles(directory);
|
|
1945
|
+
if (uncommittedFiles.length === 0) return null;
|
|
1946
|
+
return {
|
|
1947
|
+
currentBranch,
|
|
1948
|
+
baseBranch,
|
|
1949
|
+
changedFiles: uncommittedFiles,
|
|
1950
|
+
isCurrentChanges: true
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
const changedFiles = getChangedFilesSinceBranch(directory, baseBranch);
|
|
1954
|
+
if (changedFiles === null) return null;
|
|
1955
|
+
return {
|
|
1956
|
+
currentBranch,
|
|
1957
|
+
baseBranch,
|
|
1958
|
+
changedFiles
|
|
1959
|
+
};
|
|
1960
|
+
};
|
|
1961
|
+
const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_FILE_PATTERN.test(filePath));
|
|
1962
|
+
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
1963
|
+
const BOOLEAN_FIELD_NAMES = [
|
|
1964
|
+
"lint",
|
|
1965
|
+
"verbose",
|
|
1966
|
+
"customRulesOnly",
|
|
1967
|
+
"share",
|
|
1968
|
+
"respectInlineDisables",
|
|
1969
|
+
"adoptExistingLintConfig",
|
|
1970
|
+
"offline"
|
|
1971
|
+
];
|
|
1972
|
+
const STRING_FIELD_NAMES = ["rootDir"];
|
|
1973
|
+
const warnConfigField = (message) => {
|
|
1974
|
+
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1975
|
+
};
|
|
1976
|
+
const coerceMaybeBooleanString = (fieldName, value) => {
|
|
1977
|
+
if (typeof value === "boolean" || value === void 0) return value;
|
|
1978
|
+
if (value === "true") {
|
|
1979
|
+
warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
|
|
1980
|
+
return true;
|
|
1981
|
+
}
|
|
1982
|
+
if (value === "false") {
|
|
1983
|
+
warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
|
|
1984
|
+
return false;
|
|
1985
|
+
}
|
|
1986
|
+
warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1987
|
+
};
|
|
1988
|
+
const validateString = (fieldName, value) => {
|
|
1989
|
+
if (typeof value === "string") return value;
|
|
1990
|
+
warnConfigField(`config field "${fieldName}" must be a string (got ${typeof value}); ignoring this field.`);
|
|
1991
|
+
};
|
|
1992
|
+
const validateConfigTypes = (config) => {
|
|
1993
|
+
const validated = { ...config };
|
|
1994
|
+
for (const fieldName of BOOLEAN_FIELD_NAMES) {
|
|
1995
|
+
const original = config[fieldName];
|
|
1996
|
+
if (original === void 0) continue;
|
|
1997
|
+
const coerced = coerceMaybeBooleanString(fieldName, original);
|
|
1998
|
+
if (coerced === void 0) delete validated[fieldName];
|
|
1999
|
+
else validated[fieldName] = coerced;
|
|
2000
|
+
}
|
|
2001
|
+
for (const fieldName of STRING_FIELD_NAMES) {
|
|
2002
|
+
const original = config[fieldName];
|
|
2003
|
+
if (original === void 0) continue;
|
|
2004
|
+
const validatedString = validateString(fieldName, original);
|
|
2005
|
+
if (validatedString === void 0) delete validated[fieldName];
|
|
2006
|
+
else validated[fieldName] = validatedString;
|
|
2007
|
+
}
|
|
2008
|
+
return validated;
|
|
2009
|
+
};
|
|
2010
|
+
const CONFIG_FILENAME = "react-doctor.config.json";
|
|
2011
|
+
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";
|
|
2012
|
+
const loadConfigFromDirectory = (directory) => {
|
|
2013
|
+
const configFilePath = path.join(directory, CONFIG_FILENAME);
|
|
2014
|
+
if (isFile(configFilePath)) try {
|
|
2015
|
+
const fileContent = fs.readFileSync(configFilePath, "utf-8");
|
|
2016
|
+
const parsed = JSON.parse(fileContent);
|
|
2017
|
+
if (isPlainObject(parsed)) return {
|
|
2018
|
+
config: validateConfigTypes(parsed),
|
|
2019
|
+
sourceDirectory: directory
|
|
2020
|
+
};
|
|
2021
|
+
logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
logger.warn(`Failed to parse ${CONFIG_FILENAME}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2024
|
+
}
|
|
2025
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
2026
|
+
if (isFile(packageJsonPath)) try {
|
|
2027
|
+
const fileContent = fs.readFileSync(packageJsonPath, "utf-8");
|
|
2028
|
+
const packageJson = JSON.parse(fileContent);
|
|
2029
|
+
if (isPlainObject(packageJson)) {
|
|
2030
|
+
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
|
|
2031
|
+
if (isPlainObject(embeddedConfig)) return {
|
|
2032
|
+
config: validateConfigTypes(embeddedConfig),
|
|
2033
|
+
sourceDirectory: directory
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
} catch {
|
|
2037
|
+
return null;
|
|
2038
|
+
}
|
|
2039
|
+
return null;
|
|
2040
|
+
};
|
|
2041
|
+
const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
2042
|
+
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
2043
|
+
const clearConfigCache = () => {
|
|
2044
|
+
cachedConfigs.clear();
|
|
2045
|
+
};
|
|
2046
|
+
const loadConfigWithSource = (rootDirectory) => {
|
|
2047
|
+
const cached = cachedConfigs.get(rootDirectory);
|
|
2048
|
+
if (cached !== void 0) return cached;
|
|
2049
|
+
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
2050
|
+
if (localConfig) {
|
|
2051
|
+
cachedConfigs.set(rootDirectory, localConfig);
|
|
2052
|
+
return localConfig;
|
|
2053
|
+
}
|
|
2054
|
+
if (isProjectBoundary(rootDirectory)) {
|
|
2055
|
+
cachedConfigs.set(rootDirectory, null);
|
|
2056
|
+
return null;
|
|
2057
|
+
}
|
|
2058
|
+
let ancestorDirectory = path.dirname(rootDirectory);
|
|
2059
|
+
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
2060
|
+
const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
|
|
2061
|
+
if (ancestorConfig) {
|
|
2062
|
+
cachedConfigs.set(rootDirectory, ancestorConfig);
|
|
2063
|
+
return ancestorConfig;
|
|
2064
|
+
}
|
|
2065
|
+
if (isProjectBoundary(ancestorDirectory)) {
|
|
2066
|
+
cachedConfigs.set(rootDirectory, null);
|
|
2067
|
+
return null;
|
|
2068
|
+
}
|
|
2069
|
+
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
2070
|
+
}
|
|
2071
|
+
cachedConfigs.set(rootDirectory, null);
|
|
2072
|
+
return null;
|
|
2073
|
+
};
|
|
2074
|
+
const DISABLE_DIRECTIVE_PATTERN = /(eslint|oxlint)-disable/;
|
|
2075
|
+
const findFilesWithDisableDirectivesViaGit = (rootDirectory, includePaths) => {
|
|
2076
|
+
const grepArgs = [
|
|
2077
|
+
"grep",
|
|
2078
|
+
"-l",
|
|
2079
|
+
"--untracked",
|
|
2080
|
+
"-E",
|
|
2081
|
+
"(eslint|oxlint)-disable"
|
|
2082
|
+
];
|
|
2083
|
+
if (includePaths && includePaths.length > 0) grepArgs.push("--", ...includePaths);
|
|
2084
|
+
const result = spawnSync("git", grepArgs, {
|
|
2085
|
+
cwd: rootDirectory,
|
|
2086
|
+
encoding: "utf-8",
|
|
2087
|
+
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
2088
|
+
});
|
|
2089
|
+
if (result.error || result.status === null) return null;
|
|
2090
|
+
if (result.status === 128) return null;
|
|
2091
|
+
return result.stdout.split("\n").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
|
|
2092
|
+
};
|
|
2093
|
+
const findFilesWithDisableDirectivesViaFilesystem = (rootDirectory, includePaths) => {
|
|
2094
|
+
const matches = [];
|
|
2095
|
+
const checkFile = (relativePath) => {
|
|
2096
|
+
if (!SOURCE_FILE_PATTERN.test(relativePath)) return;
|
|
2097
|
+
const absolutePath = path.join(rootDirectory, relativePath);
|
|
2098
|
+
let content;
|
|
2099
|
+
try {
|
|
2100
|
+
content = fs.readFileSync(absolutePath, "utf-8");
|
|
2101
|
+
} catch {
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
if (DISABLE_DIRECTIVE_PATTERN.test(content)) matches.push(relativePath);
|
|
2105
|
+
};
|
|
2106
|
+
if (includePaths && includePaths.length > 0) {
|
|
2107
|
+
for (const candidate of includePaths) checkFile(candidate);
|
|
2108
|
+
return matches;
|
|
2109
|
+
}
|
|
2110
|
+
const stack = [rootDirectory];
|
|
2111
|
+
while (stack.length > 0) {
|
|
2112
|
+
const current = stack.pop();
|
|
2113
|
+
if (current === void 0) continue;
|
|
2114
|
+
let entries;
|
|
2115
|
+
try {
|
|
2116
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
2117
|
+
} catch {
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
for (const entry of entries) {
|
|
2121
|
+
if (entry.isDirectory()) {
|
|
2122
|
+
if (entry.name.startsWith(".") || IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
2123
|
+
stack.push(path.join(current, entry.name));
|
|
2124
|
+
continue;
|
|
2125
|
+
}
|
|
2126
|
+
if (!entry.isFile()) continue;
|
|
2127
|
+
const absolute = path.join(current, entry.name);
|
|
2128
|
+
checkFile(path.relative(rootDirectory, absolute));
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
return matches;
|
|
2132
|
+
};
|
|
2133
|
+
const findFilesWithDisableDirectives = (rootDirectory, includePaths) => findFilesWithDisableDirectivesViaGit(rootDirectory, includePaths) ?? findFilesWithDisableDirectivesViaFilesystem(rootDirectory, includePaths);
|
|
2134
|
+
const neutralizeContent = (content) => content.replaceAll("eslint-disable", "eslint_disable").replaceAll("oxlint-disable", "oxlint_disable");
|
|
2135
|
+
const neutralizeDisableDirectives = (rootDirectory, includePaths) => {
|
|
2136
|
+
const filePaths = findFilesWithDisableDirectives(rootDirectory, includePaths);
|
|
2137
|
+
const originalContents = /* @__PURE__ */ new Map();
|
|
2138
|
+
let isRestored = false;
|
|
2139
|
+
const restore = () => {
|
|
2140
|
+
if (isRestored) return;
|
|
2141
|
+
isRestored = true;
|
|
2142
|
+
for (const [absolutePath, originalContent] of originalContents) try {
|
|
2143
|
+
fs.writeFileSync(absolutePath, originalContent);
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
process.stderr.write(`[react-doctor] Failed to restore inline disable directives in ${absolutePath}: ${error instanceof Error ? error.message : String(error)}\n[react-doctor] Run: git checkout -- ${absolutePath}\n`);
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
const onExit = () => restore();
|
|
2149
|
+
process.once("exit", onExit);
|
|
2150
|
+
for (const relativePath of filePaths) {
|
|
2151
|
+
const absolutePath = path.join(rootDirectory, relativePath);
|
|
2152
|
+
let originalContent;
|
|
2153
|
+
try {
|
|
2154
|
+
originalContent = fs.readFileSync(absolutePath, "utf-8");
|
|
2155
|
+
} catch {
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
const neutralizedContent = neutralizeContent(originalContent);
|
|
2159
|
+
if (neutralizedContent !== originalContent) {
|
|
2160
|
+
originalContents.set(absolutePath, originalContent);
|
|
2161
|
+
fs.writeFileSync(absolutePath, neutralizedContent);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
return () => {
|
|
2165
|
+
restore();
|
|
2166
|
+
process.removeListener("exit", onExit);
|
|
2167
|
+
};
|
|
2168
|
+
};
|
|
2169
|
+
const resolveConfigRootDir = (config, configSourceDirectory) => {
|
|
2170
|
+
if (!config || !configSourceDirectory) return null;
|
|
2171
|
+
const rawRootDir = config.rootDir;
|
|
2172
|
+
if (typeof rawRootDir !== "string") return null;
|
|
2173
|
+
const trimmedRootDir = rawRootDir.trim();
|
|
2174
|
+
if (trimmedRootDir.length === 0) return null;
|
|
2175
|
+
const resolvedRootDir = path.isAbsolute(trimmedRootDir) ? trimmedRootDir : path.resolve(configSourceDirectory, trimmedRootDir);
|
|
2176
|
+
if (resolvedRootDir === configSourceDirectory) return null;
|
|
2177
|
+
if (!fs.existsSync(resolvedRootDir) || !fs.statSync(resolvedRootDir).isDirectory()) {
|
|
2178
|
+
logger.warn(`react-doctor config "rootDir" points to "${rawRootDir}" (resolved to ${resolvedRootDir}), which is not a directory. Ignoring.`);
|
|
2179
|
+
return null;
|
|
2180
|
+
}
|
|
2181
|
+
return resolvedRootDir;
|
|
2182
|
+
};
|
|
2183
|
+
const resolveDiagnoseTarget = (directory) => {
|
|
2184
|
+
if (isFile(path.join(directory, "package.json"))) return directory;
|
|
2185
|
+
const reactSubprojects = discoverReactSubprojects(directory);
|
|
2186
|
+
if (reactSubprojects.length === 0) return null;
|
|
2187
|
+
if (reactSubprojects.length === 1) return reactSubprojects[0].directory;
|
|
2188
|
+
throw new AmbiguousProjectError(directory, reactSubprojects.map((subproject) => path.relative(directory, subproject.directory)).toSorted());
|
|
2189
|
+
};
|
|
2190
|
+
const listSourceFilesViaGit = (rootDirectory) => {
|
|
2191
|
+
const result = spawnSync("git", [
|
|
2192
|
+
"ls-files",
|
|
2193
|
+
"-z",
|
|
2194
|
+
"--cached",
|
|
2195
|
+
"--others",
|
|
2196
|
+
"--exclude-standard"
|
|
2197
|
+
], {
|
|
2198
|
+
cwd: rootDirectory,
|
|
2199
|
+
encoding: "utf-8",
|
|
2200
|
+
maxBuffer: GIT_LS_FILES_MAX_BUFFER_BYTES
|
|
2201
|
+
});
|
|
2202
|
+
if (result.error || result.status !== 0) return null;
|
|
2203
|
+
return result.stdout.split("\0").filter((filePath) => filePath.length > 0 && SOURCE_FILE_PATTERN.test(filePath));
|
|
2204
|
+
};
|
|
2205
|
+
const listSourceFilesViaFilesystem = (rootDirectory) => {
|
|
2206
|
+
const filePaths = [];
|
|
2207
|
+
const stack = [rootDirectory];
|
|
2208
|
+
while (stack.length > 0) {
|
|
2209
|
+
const currentDirectory = stack.pop();
|
|
2210
|
+
const entries = fs.readdirSync(currentDirectory, { withFileTypes: true });
|
|
2211
|
+
for (const entry of entries) {
|
|
2212
|
+
const absolutePath = path.join(currentDirectory, entry.name);
|
|
2213
|
+
if (entry.isDirectory()) {
|
|
2214
|
+
if (!entry.name.startsWith(".") && !IGNORED_DIRECTORIES.has(entry.name)) stack.push(absolutePath);
|
|
2215
|
+
continue;
|
|
2216
|
+
}
|
|
2217
|
+
if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) filePaths.push(path.relative(rootDirectory, absolutePath).replace(/\\/g, "/"));
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return filePaths;
|
|
2221
|
+
};
|
|
2222
|
+
const listSourceFiles = (rootDirectory) => listSourceFilesViaGit(rootDirectory) ?? listSourceFilesViaFilesystem(rootDirectory);
|
|
2223
|
+
const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
2224
|
+
if (!Array.isArray(userConfig?.ignore?.files) || userConfig.ignore.files.length === 0) return;
|
|
2225
|
+
const ignoredPatterns = compileIgnoredFilePatterns(userConfig);
|
|
2226
|
+
return listSourceFiles(rootDirectory).filter((filePath) => {
|
|
2227
|
+
if (!JSX_FILE_PATTERN.test(filePath)) return false;
|
|
2228
|
+
return !isFileIgnoredByPatterns(filePath, rootDirectory, ignoredPatterns);
|
|
2229
|
+
});
|
|
2230
|
+
};
|
|
2231
|
+
const dedupeDiagnostics = (diagnostics) => {
|
|
2232
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
2233
|
+
const uniqueDiagnostics = [];
|
|
2234
|
+
for (const diagnostic of diagnostics) {
|
|
2235
|
+
const key = `${diagnostic.filePath}\u0000${diagnostic.line}\u0000${diagnostic.column}\u0000${diagnostic.plugin}\u0000${diagnostic.rule}\u0000${diagnostic.severity}\u0000${diagnostic.message}`;
|
|
2236
|
+
if (seenKeys.has(key)) continue;
|
|
2237
|
+
seenKeys.add(key);
|
|
2238
|
+
uniqueDiagnostics.push(diagnostic);
|
|
2239
|
+
}
|
|
2240
|
+
return uniqueDiagnostics;
|
|
2241
|
+
};
|
|
2242
|
+
const buildCapabilities = (project) => {
|
|
2243
|
+
const capabilities = /* @__PURE__ */ new Set();
|
|
2244
|
+
capabilities.add(project.framework);
|
|
2245
|
+
if (project.framework === "expo" || project.framework === "react-native") capabilities.add("react-native");
|
|
2246
|
+
const reactMajor = project.reactMajorVersion;
|
|
2247
|
+
if (reactMajor !== null) for (let major = 17; major <= reactMajor; major++) capabilities.add(`react:${major}`);
|
|
2248
|
+
if (project.tailwindVersion !== null) {
|
|
2249
|
+
capabilities.add("tailwind");
|
|
2250
|
+
if (isTailwindAtLeast(parseTailwindMajorMinor(project.tailwindVersion), {
|
|
2251
|
+
major: 3,
|
|
2252
|
+
minor: 4
|
|
2253
|
+
})) capabilities.add("tailwind:3.4");
|
|
2254
|
+
}
|
|
2255
|
+
if (project.hasReactCompiler) capabilities.add("react-compiler");
|
|
2256
|
+
if (project.hasTanStackQuery) capabilities.add("tanstack-query");
|
|
2257
|
+
if (project.hasTypeScript) capabilities.add("typescript");
|
|
2258
|
+
return capabilities;
|
|
2259
|
+
};
|
|
2260
|
+
const shouldEnableRule = (requires, tags, capabilities, ignoredTags) => {
|
|
2261
|
+
if (requires) {
|
|
2262
|
+
for (const capability of requires) if (!capabilities.has(capability)) return false;
|
|
2263
|
+
}
|
|
2264
|
+
if (tags) {
|
|
2265
|
+
for (const tag of tags) if (ignoredTags.has(tag)) return false;
|
|
2266
|
+
}
|
|
2267
|
+
return true;
|
|
2268
|
+
};
|
|
2269
|
+
const esmRequire$1 = createRequire(import.meta.url);
|
|
2270
|
+
const readPluginRuleNames = (pluginSpecifier) => {
|
|
2271
|
+
try {
|
|
2272
|
+
const pluginModule = esmRequire$1(pluginSpecifier);
|
|
2273
|
+
const rules = pluginModule.rules ?? pluginModule.default?.rules;
|
|
2274
|
+
if (rules === void 0) return /* @__PURE__ */ new Set();
|
|
2275
|
+
return new Set(Object.keys(rules));
|
|
2276
|
+
} catch {
|
|
2277
|
+
return /* @__PURE__ */ new Set();
|
|
2278
|
+
}
|
|
2279
|
+
};
|
|
2280
|
+
const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
|
|
2281
|
+
if (!hasReactCompiler || customRulesOnly) return null;
|
|
2282
|
+
let pluginSpecifier;
|
|
2283
|
+
try {
|
|
2284
|
+
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-hooks");
|
|
2285
|
+
} catch {
|
|
2286
|
+
return null;
|
|
2287
|
+
}
|
|
2288
|
+
return {
|
|
2289
|
+
entry: {
|
|
2290
|
+
name: "react-hooks-js",
|
|
2291
|
+
specifier: pluginSpecifier
|
|
2292
|
+
},
|
|
2293
|
+
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2294
|
+
};
|
|
2295
|
+
};
|
|
2296
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE = "effect";
|
|
2297
|
+
const resolveYouMightNotNeedEffectPlugin = (customRulesOnly) => {
|
|
2298
|
+
if (customRulesOnly) return null;
|
|
2299
|
+
let pluginSpecifier;
|
|
2300
|
+
try {
|
|
2301
|
+
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-you-might-not-need-an-effect");
|
|
2302
|
+
} catch {
|
|
2303
|
+
return null;
|
|
2304
|
+
}
|
|
2305
|
+
return {
|
|
2306
|
+
entry: {
|
|
2307
|
+
name: YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE,
|
|
2308
|
+
specifier: pluginSpecifier
|
|
2309
|
+
},
|
|
2310
|
+
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
2311
|
+
};
|
|
2312
|
+
};
|
|
2313
|
+
const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
|
|
2314
|
+
if (availableRuleNames.size === 0) return rules;
|
|
2315
|
+
const ruleKeyPrefix = `${pluginNamespace}/`;
|
|
2316
|
+
const filtered = {};
|
|
2317
|
+
for (const [ruleKey, severity] of Object.entries(rules)) {
|
|
2318
|
+
if (!ruleKey.startsWith(ruleKeyPrefix)) {
|
|
2319
|
+
filtered[ruleKey] = severity;
|
|
2320
|
+
continue;
|
|
2321
|
+
}
|
|
2322
|
+
const ruleName = ruleKey.slice(ruleKeyPrefix.length);
|
|
2323
|
+
if (availableRuleNames.has(ruleName)) filtered[ruleKey] = severity;
|
|
2324
|
+
}
|
|
2325
|
+
return filtered;
|
|
2326
|
+
};
|
|
2327
|
+
const REACT_COMPILER_RULES = {
|
|
2328
|
+
"react-hooks-js/set-state-in-render": "error",
|
|
2329
|
+
"react-hooks-js/immutability": "error",
|
|
2330
|
+
"react-hooks-js/refs": "error",
|
|
2331
|
+
"react-hooks-js/purity": "error",
|
|
2332
|
+
"react-hooks-js/hooks": "error",
|
|
2333
|
+
"react-hooks-js/set-state-in-effect": "error",
|
|
2334
|
+
"react-hooks-js/globals": "error",
|
|
2335
|
+
"react-hooks-js/error-boundaries": "error",
|
|
2336
|
+
"react-hooks-js/preserve-manual-memoization": "error",
|
|
2337
|
+
"react-hooks-js/unsupported-syntax": "error",
|
|
2338
|
+
"react-hooks-js/component-hook-factories": "error",
|
|
2339
|
+
"react-hooks-js/static-components": "error",
|
|
2340
|
+
"react-hooks-js/use-memo": "error",
|
|
2341
|
+
"react-hooks-js/void-use-memo": "error",
|
|
2342
|
+
"react-hooks-js/incompatible-library": "error",
|
|
2343
|
+
"react-hooks-js/todo": "error"
|
|
2344
|
+
};
|
|
2345
|
+
const YOU_MIGHT_NOT_NEED_EFFECT_RULES = {
|
|
2346
|
+
"effect/no-derived-state": "warn",
|
|
2347
|
+
"effect/no-chain-state-updates": "warn",
|
|
2348
|
+
"effect/no-event-handler": "warn",
|
|
2349
|
+
"effect/no-adjust-state-on-prop-change": "warn",
|
|
2350
|
+
"effect/no-reset-all-state-on-prop-change": "warn",
|
|
2351
|
+
"effect/no-pass-live-state-to-parent": "warn",
|
|
2352
|
+
"effect/no-pass-data-to-parent": "warn",
|
|
2353
|
+
"effect/no-initialize-state": "warn"
|
|
2354
|
+
};
|
|
2355
|
+
const BUILTIN_REACT_RULES = {
|
|
2356
|
+
"react/rules-of-hooks": "error",
|
|
2357
|
+
"react/no-direct-mutation-state": "error",
|
|
2358
|
+
"react/jsx-no-duplicate-props": "error",
|
|
2359
|
+
"react/jsx-key": "error",
|
|
2360
|
+
"react/no-children-prop": "warn",
|
|
2361
|
+
"react/no-danger": "warn",
|
|
2362
|
+
"react/jsx-no-script-url": "error",
|
|
2363
|
+
"react/no-render-return-value": "warn",
|
|
2364
|
+
"react/no-string-refs": "warn",
|
|
2365
|
+
"react/no-is-mounted": "warn",
|
|
2366
|
+
"react/require-render-return": "error",
|
|
2367
|
+
"react/no-unknown-property": "warn"
|
|
2368
|
+
};
|
|
2369
|
+
const BUILTIN_A11Y_RULES = {
|
|
2370
|
+
"jsx-a11y/alt-text": "error",
|
|
2371
|
+
"jsx-a11y/anchor-is-valid": "warn",
|
|
2372
|
+
"jsx-a11y/click-events-have-key-events": "warn",
|
|
2373
|
+
"jsx-a11y/no-static-element-interactions": "warn",
|
|
2374
|
+
"jsx-a11y/role-has-required-aria-props": "error",
|
|
2375
|
+
"jsx-a11y/no-autofocus": "warn",
|
|
2376
|
+
"jsx-a11y/heading-has-content": "warn",
|
|
2377
|
+
"jsx-a11y/html-has-lang": "warn",
|
|
2378
|
+
"jsx-a11y/no-redundant-roles": "warn",
|
|
2379
|
+
"jsx-a11y/scope": "warn",
|
|
2380
|
+
"jsx-a11y/tabindex-no-positive": "warn",
|
|
2381
|
+
"jsx-a11y/label-has-associated-control": "warn",
|
|
2382
|
+
"jsx-a11y/no-distracting-elements": "error",
|
|
2383
|
+
"jsx-a11y/iframe-has-title": "warn"
|
|
2384
|
+
};
|
|
2385
|
+
const createOxlintConfig = ({ pluginPath, project, customRulesOnly = false, extendsPaths = [], ignoredTags = /* @__PURE__ */ new Set() }) => {
|
|
2386
|
+
const reactHooksJsPlugin = resolveReactHooksJsPlugin(project.hasReactCompiler, customRulesOnly);
|
|
2387
|
+
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
2388
|
+
const youMightNotNeedEffectPlugin = resolveYouMightNotNeedEffectPlugin(customRulesOnly);
|
|
2389
|
+
const youMightNotNeedEffectRules = youMightNotNeedEffectPlugin ? filterRulesToAvailable(YOU_MIGHT_NOT_NEED_EFFECT_RULES, YOU_MIGHT_NOT_NEED_EFFECT_NAMESPACE, youMightNotNeedEffectPlugin.availableRuleNames) : {};
|
|
2390
|
+
const jsPlugins = [];
|
|
2391
|
+
if (reactHooksJsPlugin) jsPlugins.push(reactHooksJsPlugin.entry);
|
|
2392
|
+
if (youMightNotNeedEffectPlugin) jsPlugins.push(youMightNotNeedEffectPlugin.entry);
|
|
2393
|
+
const capabilities = buildCapabilities(project);
|
|
2394
|
+
const enabledReactDoctorRules = {};
|
|
2395
|
+
for (const [ruleId, rule] of Object.entries(reactDoctorPlugin.rules)) {
|
|
2396
|
+
const fullKey = `react-doctor/${ruleId}`;
|
|
2397
|
+
if (rule.framework !== "global" && !rule.requires) continue;
|
|
2398
|
+
if (!shouldEnableRule(rule.requires, rule.tags, capabilities, ignoredTags)) continue;
|
|
2399
|
+
enabledReactDoctorRules[fullKey] = rule.severity;
|
|
2400
|
+
}
|
|
2401
|
+
return {
|
|
2402
|
+
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
2403
|
+
categories: {
|
|
2404
|
+
correctness: "off",
|
|
2405
|
+
suspicious: "off",
|
|
2406
|
+
pedantic: "off",
|
|
2407
|
+
perf: "off",
|
|
2408
|
+
restriction: "off",
|
|
2409
|
+
style: "off",
|
|
2410
|
+
nursery: "off"
|
|
2411
|
+
},
|
|
2412
|
+
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
2413
|
+
jsPlugins: [...jsPlugins, pluginPath],
|
|
2414
|
+
rules: {
|
|
2415
|
+
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
2416
|
+
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
2417
|
+
...reactCompilerRules,
|
|
2418
|
+
...youMightNotNeedEffectRules,
|
|
2419
|
+
...enabledReactDoctorRules
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
};
|
|
2423
|
+
const REACT_MODULE_SOURCE = "react";
|
|
2424
|
+
const REQUIRE_IDENTIFIER = "require";
|
|
2425
|
+
const USE_IDENTIFIER = "use";
|
|
2426
|
+
const LOCAL_BINDING_RESOLUTION = {
|
|
2427
|
+
isReactUseBinding: false,
|
|
2428
|
+
isReactNamespaceBinding: false
|
|
2429
|
+
};
|
|
2430
|
+
const REACT_NAMESPACE_BINDING_RESOLUTION = {
|
|
2431
|
+
isReactUseBinding: false,
|
|
2432
|
+
isReactNamespaceBinding: true
|
|
2433
|
+
};
|
|
2434
|
+
const REACT_USE_BINDING_RESOLUTION = {
|
|
2435
|
+
isReactUseBinding: true,
|
|
2436
|
+
isReactNamespaceBinding: false
|
|
2437
|
+
};
|
|
2438
|
+
const getScriptKind = (filename) => {
|
|
2439
|
+
if (filename.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
2440
|
+
if (filename.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
2441
|
+
if (filename.endsWith(".ts")) return ts.ScriptKind.TS;
|
|
2442
|
+
return ts.ScriptKind.JS;
|
|
2443
|
+
};
|
|
2444
|
+
const getUtf16Offset = (sourceText, utf8Offset) => Buffer.from(sourceText).subarray(0, utf8Offset).toString("utf8").length;
|
|
2445
|
+
const unwrapExpression = (expression) => {
|
|
2446
|
+
let currentExpression = expression;
|
|
2447
|
+
while (ts.isParenthesizedExpression(currentExpression) || ts.isAsExpression(currentExpression) || ts.isSatisfiesExpression(currentExpression) || ts.isNonNullExpression(currentExpression) || ts.isTypeAssertionExpression(currentExpression)) currentExpression = currentExpression.expression;
|
|
2448
|
+
return currentExpression;
|
|
2449
|
+
};
|
|
2450
|
+
const getStaticPropertyName = (node) => {
|
|
2451
|
+
if (!node) return null;
|
|
2452
|
+
if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return node.text;
|
|
2453
|
+
if (ts.isComputedPropertyName(node)) {
|
|
2454
|
+
const expression = unwrapExpression(node.expression);
|
|
2455
|
+
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
|
|
2456
|
+
}
|
|
2457
|
+
return null;
|
|
2458
|
+
};
|
|
2459
|
+
const findBindingIdentifier = (bindingName, identifierName) => {
|
|
2460
|
+
if (ts.isIdentifier(bindingName)) return bindingName.text === identifierName ? bindingName : null;
|
|
2461
|
+
for (const element of bindingName.elements) {
|
|
2462
|
+
if (ts.isOmittedExpression(element)) continue;
|
|
2463
|
+
const nestedIdentifier = findBindingIdentifier(element.name, identifierName);
|
|
2464
|
+
if (nestedIdentifier) return nestedIdentifier;
|
|
2465
|
+
}
|
|
2466
|
+
return null;
|
|
2467
|
+
};
|
|
2468
|
+
const bindingNameHasIdentifier = (bindingName, identifierName) => {
|
|
2469
|
+
if (ts.isIdentifier(bindingName)) return bindingName.text === identifierName;
|
|
2470
|
+
return bindingName.elements.some((element) => {
|
|
2471
|
+
if (ts.isOmittedExpression(element)) return false;
|
|
2472
|
+
return bindingNameHasIdentifier(element.name, identifierName);
|
|
2473
|
+
});
|
|
2474
|
+
};
|
|
2475
|
+
const getDirectBindingIdentifier = (bindingName) => ts.isIdentifier(bindingName) ? bindingName : null;
|
|
2476
|
+
const isReactUseObjectBindingElement = (bindingElement) => {
|
|
2477
|
+
const bindingIdentifier = getDirectBindingIdentifier(bindingElement.name);
|
|
2478
|
+
if (!bindingIdentifier) return false;
|
|
2479
|
+
if (!bindingElement.propertyName) return bindingIdentifier.text === USE_IDENTIFIER;
|
|
2480
|
+
return getStaticPropertyName(bindingElement.propertyName) === USE_IDENTIFIER;
|
|
2481
|
+
};
|
|
2482
|
+
const isReactRequireCall = (expression) => {
|
|
2483
|
+
const unwrappedExpression = unwrapExpression(expression);
|
|
2484
|
+
return ts.isCallExpression(unwrappedExpression) && ts.isIdentifier(unwrappedExpression.expression) && unwrappedExpression.expression.text === REQUIRE_IDENTIFIER && unwrappedExpression.arguments.length === 1 && ts.isStringLiteral(unwrappedExpression.arguments[0]) && unwrappedExpression.arguments[0].text === REACT_MODULE_SOURCE;
|
|
2485
|
+
};
|
|
2486
|
+
const getModuleSource = (node) => {
|
|
2487
|
+
let currentNode = node;
|
|
2488
|
+
while (currentNode) {
|
|
2489
|
+
if (ts.isImportDeclaration(currentNode) && ts.isStringLiteral(currentNode.moduleSpecifier)) return currentNode.moduleSpecifier.text;
|
|
2490
|
+
currentNode = currentNode.parent;
|
|
2491
|
+
}
|
|
2492
|
+
return null;
|
|
2493
|
+
};
|
|
2494
|
+
const getImportedName = (importSpecifier) => importSpecifier.propertyName?.text ?? importSpecifier.name.text;
|
|
2495
|
+
const collectReactObjectBindingNames = (bindingPattern, useImportNames) => {
|
|
2496
|
+
for (const bindingElement of bindingPattern.elements) {
|
|
2497
|
+
const bindingIdentifier = getDirectBindingIdentifier(bindingElement.name);
|
|
2498
|
+
if (bindingIdentifier && isReactUseObjectBindingElement(bindingElement)) useImportNames.add(bindingIdentifier.text);
|
|
2499
|
+
}
|
|
2500
|
+
};
|
|
2501
|
+
const isReactObjectBindingName = (bindingPattern, identifierName) => bindingPattern.elements.some((bindingElement) => {
|
|
2502
|
+
if (getDirectBindingIdentifier(bindingElement.name)?.text !== identifierName) return false;
|
|
2503
|
+
return isReactUseObjectBindingElement(bindingElement);
|
|
2504
|
+
});
|
|
2505
|
+
const isReactRequireBindingDeclaration = (node, identifierName) => {
|
|
2506
|
+
if (!ts.isVariableDeclaration(node)) return false;
|
|
2507
|
+
if (!node.initializer) return false;
|
|
2508
|
+
if (!isReactRequireCall(node.initializer)) return false;
|
|
2509
|
+
if (ts.isIdentifier(node.name)) return node.name.text === identifierName;
|
|
2510
|
+
return ts.isObjectBindingPattern(node.name) && isReactObjectBindingName(node.name, identifierName);
|
|
2511
|
+
};
|
|
2512
|
+
const collectReactImportBindings = (sourceFile) => {
|
|
2513
|
+
const namespaceNames = /* @__PURE__ */ new Set();
|
|
2514
|
+
const useImportNames = /* @__PURE__ */ new Set();
|
|
2515
|
+
for (const statement of sourceFile.statements) {
|
|
2516
|
+
if (ts.isImportDeclaration(statement)) {
|
|
2517
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
2518
|
+
if (statement.moduleSpecifier.text !== REACT_MODULE_SOURCE) continue;
|
|
2519
|
+
const importClause = statement.importClause;
|
|
2520
|
+
if (!importClause) continue;
|
|
2521
|
+
if (importClause.name) namespaceNames.add(importClause.name.text);
|
|
2522
|
+
const namedBindings = importClause.namedBindings;
|
|
2523
|
+
if (!namedBindings) continue;
|
|
2524
|
+
if (ts.isNamespaceImport(namedBindings)) {
|
|
2525
|
+
namespaceNames.add(namedBindings.name.text);
|
|
2526
|
+
continue;
|
|
2527
|
+
}
|
|
2528
|
+
for (const importSpecifier of namedBindings.elements) if (getImportedName(importSpecifier) === USE_IDENTIFIER) useImportNames.add(importSpecifier.name.text);
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
2532
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
2533
|
+
if (!declaration.initializer) continue;
|
|
2534
|
+
if (!isReactRequireCall(declaration.initializer)) continue;
|
|
2535
|
+
if (ts.isIdentifier(declaration.name)) {
|
|
2536
|
+
namespaceNames.add(declaration.name.text);
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
if (ts.isObjectBindingPattern(declaration.name)) collectReactObjectBindingNames(declaration.name, useImportNames);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
return {
|
|
2543
|
+
namespaceNames,
|
|
2544
|
+
useImportNames
|
|
2545
|
+
};
|
|
2546
|
+
};
|
|
2547
|
+
const findBindingElement = (identifier) => {
|
|
2548
|
+
let currentNode = identifier.parent;
|
|
2549
|
+
while (currentNode) {
|
|
2550
|
+
if (ts.isBindingElement(currentNode)) return currentNode;
|
|
2551
|
+
if (ts.isVariableDeclaration(currentNode) || ts.isParameter(currentNode)) return null;
|
|
2552
|
+
currentNode = currentNode.parent;
|
|
2553
|
+
}
|
|
2554
|
+
return null;
|
|
2555
|
+
};
|
|
2556
|
+
const declarationBindsIdentifier = (node, identifierName) => {
|
|
2557
|
+
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) return bindingNameHasIdentifier(node.name, identifierName);
|
|
2558
|
+
if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return node.name?.text === identifierName;
|
|
2559
|
+
return false;
|
|
2560
|
+
};
|
|
2561
|
+
const isScopeBoundary = (node) => ts.isFunctionLike(node) || ts.isClassLike(node) || ts.isBlock(node) || ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node) || ts.isCatchClause(node) || ts.isSourceFile(node) || ts.isModuleBlock(node);
|
|
2562
|
+
const scopeContainsNonImportBinding = (node, scopeNode, identifierName) => {
|
|
2563
|
+
if (isReactRequireBindingDeclaration(node, identifierName)) return false;
|
|
2564
|
+
if (declarationBindsIdentifier(node, identifierName)) return true;
|
|
2565
|
+
if (node !== scopeNode && isScopeBoundary(node)) return false;
|
|
2566
|
+
let didFindBinding = false;
|
|
2567
|
+
ts.forEachChild(node, (child) => {
|
|
2568
|
+
if (didFindBinding) return;
|
|
2569
|
+
didFindBinding = scopeContainsNonImportBinding(child, scopeNode, identifierName);
|
|
2570
|
+
});
|
|
2571
|
+
return didFindBinding;
|
|
2572
|
+
};
|
|
2573
|
+
const isIdentifierShadowedByLocalBinding = (identifier, sourceFile) => {
|
|
2574
|
+
let currentNode = identifier.parent;
|
|
2575
|
+
while (currentNode) {
|
|
2576
|
+
if (isScopeNode(currentNode)) {
|
|
2577
|
+
if (scopeContainsNonImportBinding(currentNode, currentNode, identifier.text)) return true;
|
|
2578
|
+
}
|
|
2579
|
+
if (currentNode === sourceFile) return false;
|
|
2580
|
+
currentNode = currentNode.parent;
|
|
2581
|
+
}
|
|
2582
|
+
return false;
|
|
2583
|
+
};
|
|
2584
|
+
const isReactNamespaceExpression = (expression, reactImportBindings, sourceFile, visitedDeclarations) => {
|
|
2585
|
+
const unwrappedExpression = unwrapExpression(expression);
|
|
2586
|
+
if (isReactRequireCall(unwrappedExpression)) return true;
|
|
2587
|
+
if (!ts.isIdentifier(unwrappedExpression)) return false;
|
|
2588
|
+
if (reactImportBindings.namespaceNames.has(unwrappedExpression.text) && !isIdentifierShadowedByLocalBinding(unwrappedExpression, sourceFile)) return true;
|
|
2589
|
+
return resolveIdentifierBinding(unwrappedExpression, reactImportBindings, sourceFile, visitedDeclarations)?.isReactNamespaceBinding ?? false;
|
|
2590
|
+
};
|
|
2591
|
+
const isReactUseExpression = (expression, reactImportBindings, sourceFile, visitedDeclarations) => {
|
|
2592
|
+
if (!expression) return false;
|
|
2593
|
+
const unwrappedExpression = unwrapExpression(expression);
|
|
2594
|
+
if (ts.isIdentifier(unwrappedExpression)) {
|
|
2595
|
+
if (reactImportBindings.useImportNames.has(unwrappedExpression.text) && !isIdentifierShadowedByLocalBinding(unwrappedExpression, sourceFile)) return true;
|
|
2596
|
+
if (unwrappedExpression.text === USE_IDENTIFIER) return false;
|
|
2597
|
+
return resolveIdentifierBinding(unwrappedExpression, reactImportBindings, sourceFile, visitedDeclarations)?.isReactUseBinding ?? false;
|
|
2598
|
+
}
|
|
2599
|
+
if (ts.isPropertyAccessExpression(unwrappedExpression) && unwrappedExpression.name.text === USE_IDENTIFIER && isReactNamespaceExpression(unwrappedExpression.expression, reactImportBindings, sourceFile, visitedDeclarations)) return true;
|
|
2600
|
+
if (ts.isElementAccessExpression(unwrappedExpression) && ts.isStringLiteral(unwrappedExpression.argumentExpression) && unwrappedExpression.argumentExpression.text === USE_IDENTIFIER) return isReactNamespaceExpression(unwrappedExpression.expression, reactImportBindings, sourceFile, visitedDeclarations);
|
|
2601
|
+
return false;
|
|
2602
|
+
};
|
|
2603
|
+
const isReactUseObjectBinding = (identifier, variableDeclaration, reactImportBindings, sourceFile, visitedDeclarations) => {
|
|
2604
|
+
const bindingElement = findBindingElement(identifier);
|
|
2605
|
+
if (!bindingElement) return false;
|
|
2606
|
+
if (!ts.isObjectBindingPattern(bindingElement.parent)) return false;
|
|
2607
|
+
if (!variableDeclaration.initializer) return false;
|
|
2608
|
+
if (!isReactNamespaceExpression(variableDeclaration.initializer, reactImportBindings, sourceFile, visitedDeclarations)) return false;
|
|
2609
|
+
return isReactUseObjectBindingElement(bindingElement);
|
|
2610
|
+
};
|
|
2611
|
+
const getVariableDeclarationResolution = (variableDeclaration, identifierName, reactImportBindings, sourceFile, visitedDeclarations) => {
|
|
2612
|
+
const bindingIdentifier = findBindingIdentifier(variableDeclaration.name, identifierName);
|
|
2613
|
+
if (!bindingIdentifier) return null;
|
|
2614
|
+
if (visitedDeclarations.has(variableDeclaration)) return null;
|
|
2615
|
+
const nestedVisitedDeclarations = new Set(visitedDeclarations);
|
|
2616
|
+
nestedVisitedDeclarations.add(variableDeclaration);
|
|
2617
|
+
return {
|
|
2618
|
+
isReactNamespaceBinding: ts.isIdentifier(variableDeclaration.name) && variableDeclaration.initializer !== void 0 && isReactNamespaceExpression(variableDeclaration.initializer, reactImportBindings, sourceFile, new Set(nestedVisitedDeclarations)),
|
|
2619
|
+
isReactUseBinding: isReactUseExpression(variableDeclaration.initializer, reactImportBindings, sourceFile, new Set(nestedVisitedDeclarations)) || isReactUseObjectBinding(bindingIdentifier, variableDeclaration, reactImportBindings, sourceFile, new Set(nestedVisitedDeclarations))
|
|
2620
|
+
};
|
|
2621
|
+
};
|
|
2622
|
+
const getImportResolution = (node, identifierName) => {
|
|
2623
|
+
if (ts.isImportSpecifier(node) && node.name.text === identifierName) return getModuleSource(node) === REACT_MODULE_SOURCE && getImportedName(node) === USE_IDENTIFIER ? REACT_USE_BINDING_RESOLUTION : LOCAL_BINDING_RESOLUTION;
|
|
2624
|
+
if (ts.isNamespaceImport(node) && node.name.text === identifierName) return getModuleSource(node) === REACT_MODULE_SOURCE ? REACT_NAMESPACE_BINDING_RESOLUTION : LOCAL_BINDING_RESOLUTION;
|
|
2625
|
+
if (ts.isImportClause(node) && node.name?.text === identifierName) return getModuleSource(node) === REACT_MODULE_SOURCE ? REACT_NAMESPACE_BINDING_RESOLUTION : LOCAL_BINDING_RESOLUTION;
|
|
2626
|
+
return null;
|
|
2627
|
+
};
|
|
2628
|
+
const getDeclarationResolution = (node, identifierName, reactImportBindings, sourceFile, visitedDeclarations) => {
|
|
2629
|
+
const importResolution = getImportResolution(node, identifierName);
|
|
2630
|
+
if (importResolution) return importResolution;
|
|
2631
|
+
if (ts.isVariableDeclaration(node)) return getVariableDeclarationResolution(node, identifierName, reactImportBindings, sourceFile, visitedDeclarations);
|
|
2632
|
+
if (ts.isParameter(node)) return bindingNameHasIdentifier(node.name, identifierName) ? LOCAL_BINDING_RESOLUTION : null;
|
|
2633
|
+
if (ts.isFunctionDeclaration(node) && node.name?.text === identifierName) return LOCAL_BINDING_RESOLUTION;
|
|
2634
|
+
if (ts.isClassDeclaration(node) && node.name?.text === identifierName) return LOCAL_BINDING_RESOLUTION;
|
|
2635
|
+
return null;
|
|
2636
|
+
};
|
|
2637
|
+
const isNestedScopeBoundary = (node, scopeNode) => node !== scopeNode && isScopeBoundary(node);
|
|
2638
|
+
const findResolutionInSubtree = (node, scopeNode, identifierName, reactImportBindings, sourceFile, visitedDeclarations) => {
|
|
2639
|
+
const declarationResolution = getDeclarationResolution(node, identifierName, reactImportBindings, sourceFile, visitedDeclarations);
|
|
2640
|
+
if (declarationResolution) return declarationResolution;
|
|
2641
|
+
if (isNestedScopeBoundary(node, scopeNode)) return null;
|
|
2642
|
+
let resolution = null;
|
|
2643
|
+
ts.forEachChild(node, (child) => {
|
|
2644
|
+
if (resolution) return;
|
|
2645
|
+
resolution = findResolutionInSubtree(child, scopeNode, identifierName, reactImportBindings, sourceFile, visitedDeclarations);
|
|
2646
|
+
});
|
|
2647
|
+
return resolution;
|
|
2648
|
+
};
|
|
2649
|
+
const findResolutionInFunctionParameters = (node, identifierName, reactImportBindings) => {
|
|
2650
|
+
if (!ts.isFunctionLike(node)) return null;
|
|
2651
|
+
for (const parameter of node.parameters) {
|
|
2652
|
+
const parameterResolution = getDeclarationResolution(parameter, identifierName, reactImportBindings, parameter.getSourceFile(), /* @__PURE__ */ new Set());
|
|
2653
|
+
if (parameterResolution) return parameterResolution;
|
|
2654
|
+
}
|
|
2655
|
+
return null;
|
|
2656
|
+
};
|
|
2657
|
+
const findResolutionInScope = (scopeNode, identifierName, reactImportBindings, sourceFile, visitedDeclarations) => {
|
|
2658
|
+
const parameterResolution = findResolutionInFunctionParameters(scopeNode, identifierName, reactImportBindings);
|
|
2659
|
+
if (parameterResolution) return parameterResolution;
|
|
2660
|
+
let resolution = null;
|
|
2661
|
+
ts.forEachChild(scopeNode, (child) => {
|
|
2662
|
+
if (resolution) return;
|
|
2663
|
+
resolution = findResolutionInSubtree(child, scopeNode, identifierName, reactImportBindings, sourceFile, visitedDeclarations);
|
|
2664
|
+
});
|
|
2665
|
+
return resolution;
|
|
2666
|
+
};
|
|
2667
|
+
const isScopeNode = isScopeBoundary;
|
|
2668
|
+
const resolveIdentifierBinding = (identifier, reactImportBindings, sourceFile, visitedDeclarations = /* @__PURE__ */ new Set()) => {
|
|
2669
|
+
let currentNode = identifier.parent;
|
|
2670
|
+
while (currentNode) {
|
|
2671
|
+
if (isScopeNode(currentNode)) {
|
|
2672
|
+
const resolution = findResolutionInScope(currentNode, identifier.text, reactImportBindings, sourceFile, visitedDeclarations);
|
|
2673
|
+
if (resolution) return resolution;
|
|
2674
|
+
}
|
|
2675
|
+
currentNode = currentNode.parent;
|
|
2676
|
+
}
|
|
2677
|
+
return null;
|
|
2678
|
+
};
|
|
2679
|
+
const isUseCallIdentifier = (node) => node.text === USE_IDENTIFIER && ts.isCallExpression(node.parent) && node.parent.expression === node;
|
|
2680
|
+
const findUseCallIdentifier = (sourceFile, useOffset) => {
|
|
2681
|
+
let matchedIdentifier = null;
|
|
2682
|
+
const visit = (node) => {
|
|
2683
|
+
if (matchedIdentifier) return;
|
|
2684
|
+
if (ts.isIdentifier(node) && isUseCallIdentifier(node) && node.getStart(sourceFile) === useOffset) {
|
|
2685
|
+
matchedIdentifier = node;
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
ts.forEachChild(node, visit);
|
|
2689
|
+
};
|
|
2690
|
+
visit(sourceFile);
|
|
2691
|
+
return matchedIdentifier;
|
|
2692
|
+
};
|
|
2693
|
+
const resolveUseCallBinding = (sourceText, filename, utf8Offset) => {
|
|
2694
|
+
const sourceFile = ts.createSourceFile(filename, sourceText, ts.ScriptTarget.Latest, true, getScriptKind(filename));
|
|
2695
|
+
const useIdentifier = findUseCallIdentifier(sourceFile, getUtf16Offset(sourceText, utf8Offset));
|
|
2696
|
+
if (!useIdentifier) return null;
|
|
2697
|
+
return resolveIdentifierBinding(useIdentifier, collectReactImportBindings(sourceFile), sourceFile);
|
|
2698
|
+
};
|
|
2699
|
+
const RULES_OF_HOOKS_CODE = "react-hooks(rules-of-hooks)";
|
|
2700
|
+
const REACT_HOOK_USE_MESSAGE_PREFIX = "React Hook \"use\"";
|
|
2701
|
+
const shouldSuppressLocalUseHookDiagnostic = (diagnostic, rootDirectory) => {
|
|
2702
|
+
if (diagnostic.code !== RULES_OF_HOOKS_CODE) return false;
|
|
2703
|
+
if (!diagnostic.message.startsWith(REACT_HOOK_USE_MESSAGE_PREFIX)) return false;
|
|
2704
|
+
const primaryLabel = diagnostic.labels[0];
|
|
2705
|
+
if (!primaryLabel) return false;
|
|
2706
|
+
const absolutePath = path.isAbsolute(diagnostic.filename) ? diagnostic.filename : path.join(rootDirectory, diagnostic.filename);
|
|
2707
|
+
let sourceText;
|
|
2708
|
+
try {
|
|
2709
|
+
sourceText = fs.readFileSync(absolutePath, "utf-8");
|
|
2710
|
+
} catch {
|
|
2711
|
+
return false;
|
|
2712
|
+
}
|
|
2713
|
+
const bindingResolution = resolveUseCallBinding(sourceText, absolutePath, primaryLabel.span.offset);
|
|
2714
|
+
return bindingResolution !== null && !bindingResolution.isReactUseBinding;
|
|
2715
|
+
};
|
|
2716
|
+
const getRuleRecommendation = (ruleName) => reactDoctorPlugin.rules[ruleName]?.recommendation;
|
|
2717
|
+
const getRuleCategory = (ruleName) => reactDoctorPlugin.rules[ruleName]?.category;
|
|
2718
|
+
const esmRequire = createRequire(import.meta.url);
|
|
2719
|
+
const PLUGIN_CATEGORY_MAP = {
|
|
2720
|
+
react: "Correctness",
|
|
2721
|
+
"react-hooks": "Correctness",
|
|
2722
|
+
"react-hooks-js": "React Compiler",
|
|
2723
|
+
"react-doctor": "Other",
|
|
2724
|
+
"jsx-a11y": "Accessibility",
|
|
2725
|
+
effect: "State & Effects",
|
|
2726
|
+
eslint: "Correctness",
|
|
2727
|
+
oxc: "Correctness",
|
|
2728
|
+
typescript: "Correctness",
|
|
2729
|
+
unicorn: "Correctness",
|
|
2730
|
+
import: "Bundle Size",
|
|
2731
|
+
promise: "Correctness",
|
|
2732
|
+
n: "Correctness",
|
|
2733
|
+
node: "Correctness",
|
|
2734
|
+
vitest: "Correctness",
|
|
2735
|
+
jest: "Correctness",
|
|
2736
|
+
nextjs: "Next.js"
|
|
2737
|
+
};
|
|
2738
|
+
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
2739
|
+
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
2740
|
+
const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
2741
|
+
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
2742
|
+
if (plugin === "react-hooks-js") return {
|
|
2743
|
+
message: REACT_COMPILER_MESSAGE,
|
|
2744
|
+
help: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || help
|
|
2745
|
+
};
|
|
2746
|
+
return {
|
|
2747
|
+
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
2748
|
+
help: help || getRuleRecommendation(rule) || ""
|
|
2749
|
+
};
|
|
2750
|
+
};
|
|
2751
|
+
const parseRuleCode = (code) => {
|
|
2752
|
+
const match = code.match(/^(.+)\((.+)\)$/);
|
|
2753
|
+
if (!match) return {
|
|
2754
|
+
plugin: "unknown",
|
|
2755
|
+
rule: code
|
|
2756
|
+
};
|
|
2757
|
+
return {
|
|
2758
|
+
plugin: match[1].replace(/^eslint-plugin-/, ""),
|
|
2759
|
+
rule: match[2]
|
|
2760
|
+
};
|
|
2761
|
+
};
|
|
2762
|
+
const resolveOxlintBinary = () => {
|
|
2763
|
+
const oxlintMainPath = esmRequire.resolve("oxlint");
|
|
2764
|
+
const oxlintPackageDirectory = path.resolve(path.dirname(oxlintMainPath), "..");
|
|
2765
|
+
return path.join(oxlintPackageDirectory, "bin", "oxlint");
|
|
2766
|
+
};
|
|
2767
|
+
const resolvePluginPath = () => esmRequire.resolve("oxlint-plugin-react-doctor");
|
|
2768
|
+
const resolveDiagnosticCategory = (plugin, rule) => getRuleCategory(rule) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
|
|
2769
|
+
const SANITIZED_ENV = (() => {
|
|
2770
|
+
const sanitized = {};
|
|
2771
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
2772
|
+
if (name === "NODE_OPTIONS" || name === "NODE_DEBUG") continue;
|
|
2773
|
+
if (name.startsWith("npm_config_")) continue;
|
|
2774
|
+
sanitized[name] = value;
|
|
2775
|
+
}
|
|
2776
|
+
return sanitized;
|
|
2777
|
+
})();
|
|
2778
|
+
const OXLINT_SPAWN_TIMEOUT_MS = 6e4;
|
|
2779
|
+
const spawnOxlint = (args, rootDirectory, nodeBinaryPath) => new Promise((resolve, reject) => {
|
|
2780
|
+
const child = spawn(nodeBinaryPath, args, {
|
|
2781
|
+
cwd: rootDirectory,
|
|
2782
|
+
env: SANITIZED_ENV
|
|
2783
|
+
});
|
|
2784
|
+
const timeoutHandle = setTimeout(() => {
|
|
2785
|
+
child.kill("SIGKILL");
|
|
2786
|
+
reject(/* @__PURE__ */ new Error(`oxlint did not return within ${OXLINT_SPAWN_TIMEOUT_MS / 1e3}s — please report`));
|
|
2787
|
+
}, OXLINT_SPAWN_TIMEOUT_MS);
|
|
2788
|
+
timeoutHandle.unref?.();
|
|
2789
|
+
const stdoutBuffers = [];
|
|
2790
|
+
const stderrBuffers = [];
|
|
2791
|
+
let stdoutByteCount = 0;
|
|
2792
|
+
let stderrByteCount = 0;
|
|
2793
|
+
let didKillForSize = false;
|
|
2794
|
+
const killIfTooLarge = (incomingBytes, isStdout) => {
|
|
2795
|
+
if (isStdout) stdoutByteCount += incomingBytes;
|
|
2796
|
+
else stderrByteCount += incomingBytes;
|
|
2797
|
+
if (stdoutByteCount + stderrByteCount > 52428800 && !didKillForSize) {
|
|
2798
|
+
didKillForSize = true;
|
|
2799
|
+
child.kill("SIGKILL");
|
|
2800
|
+
return true;
|
|
2801
|
+
}
|
|
2802
|
+
return false;
|
|
2803
|
+
};
|
|
2804
|
+
child.stdout.on("data", (buffer) => {
|
|
2805
|
+
if (didKillForSize) return;
|
|
2806
|
+
stdoutBuffers.push(buffer);
|
|
2807
|
+
killIfTooLarge(buffer.length, true);
|
|
2808
|
+
});
|
|
2809
|
+
child.stderr.on("data", (buffer) => {
|
|
2810
|
+
if (didKillForSize) return;
|
|
2811
|
+
stderrBuffers.push(buffer);
|
|
2812
|
+
killIfTooLarge(buffer.length, false);
|
|
2813
|
+
});
|
|
2814
|
+
child.on("error", (error) => {
|
|
2815
|
+
clearTimeout(timeoutHandle);
|
|
2816
|
+
reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${error.message}`));
|
|
2817
|
+
});
|
|
2818
|
+
child.on("close", (_code, signal) => {
|
|
2819
|
+
clearTimeout(timeoutHandle);
|
|
2820
|
+
if (didKillForSize) {
|
|
2821
|
+
reject(/* @__PURE__ */ new Error(`oxlint output exceeded ${OXLINT_OUTPUT_MAX_BYTES} bytes — scan a smaller subset with --diff or --staged`));
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
if (signal) {
|
|
2825
|
+
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
2826
|
+
const hint = signal === "SIGABRT" ? " (out of memory — try scanning fewer files with --diff)" : "";
|
|
2827
|
+
const detail = stderrOutput ? `: ${stderrOutput}` : "";
|
|
2828
|
+
reject(/* @__PURE__ */ new Error(`oxlint was killed by ${signal}${hint}${detail}`));
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
const output = Buffer.concat(stdoutBuffers).toString("utf-8").trim();
|
|
2832
|
+
if (!output) {
|
|
2833
|
+
const stderrOutput = Buffer.concat(stderrBuffers).toString("utf-8").trim();
|
|
2834
|
+
if (stderrOutput) {
|
|
2835
|
+
reject(/* @__PURE__ */ new Error(`Failed to run oxlint: ${stderrOutput}`));
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
resolve(output);
|
|
2840
|
+
});
|
|
2841
|
+
});
|
|
2842
|
+
const isSplittableOxlintBatchError = (error) => {
|
|
2843
|
+
if (!(error instanceof Error)) return false;
|
|
2844
|
+
return error.message.includes("did not return within") || error.message.includes("output exceeded") || error.message.includes("out of memory");
|
|
2845
|
+
};
|
|
2846
|
+
const isOxlintOutput = (value) => {
|
|
2847
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2848
|
+
const candidate = value;
|
|
2849
|
+
return Array.isArray(candidate.diagnostics);
|
|
2850
|
+
};
|
|
2851
|
+
const parseOxlintOutput = (stdout, rootDirectory) => {
|
|
2852
|
+
if (!stdout) return [];
|
|
2853
|
+
const jsonStart = stdout.indexOf("{");
|
|
2854
|
+
const sanitizedStdout = jsonStart > 0 ? stdout.slice(jsonStart) : stdout;
|
|
2855
|
+
let parsed;
|
|
2856
|
+
try {
|
|
2857
|
+
parsed = JSON.parse(sanitizedStdout);
|
|
2858
|
+
} catch {
|
|
2859
|
+
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
|
|
2860
|
+
}
|
|
2861
|
+
if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
|
|
2862
|
+
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename) && !shouldSuppressLocalUseHookDiagnostic(diagnostic, rootDirectory)).map((diagnostic) => {
|
|
2863
|
+
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
2864
|
+
const primaryLabel = diagnostic.labels[0];
|
|
2865
|
+
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
2866
|
+
return {
|
|
2867
|
+
filePath: diagnostic.filename,
|
|
2868
|
+
plugin,
|
|
2869
|
+
rule,
|
|
2870
|
+
severity: diagnostic.severity,
|
|
2871
|
+
message: cleaned.message,
|
|
2872
|
+
help: cleaned.help,
|
|
2873
|
+
url: diagnostic.url,
|
|
2874
|
+
line: primaryLabel?.span.line ?? 0,
|
|
2875
|
+
column: primaryLabel?.span.column ?? 0,
|
|
2876
|
+
category: resolveDiagnosticCategory(plugin, rule)
|
|
2877
|
+
};
|
|
2878
|
+
});
|
|
2879
|
+
};
|
|
2880
|
+
const TSCONFIG_FILENAMES = ["tsconfig.json", "tsconfig.base.json"];
|
|
2881
|
+
const resolveTsConfigRelativePath = (rootDirectory) => {
|
|
2882
|
+
for (const filename of TSCONFIG_FILENAMES) if (fs.existsSync(path.join(rootDirectory, filename))) return `./${filename}`;
|
|
2883
|
+
return null;
|
|
2884
|
+
};
|
|
2885
|
+
let didValidateRuleRegistration = false;
|
|
2886
|
+
const validateRuleRegistration = () => {
|
|
2887
|
+
if (didValidateRuleRegistration) return;
|
|
2888
|
+
didValidateRuleRegistration = true;
|
|
2889
|
+
const missingHelp = [];
|
|
2890
|
+
const missingCategory = [];
|
|
2891
|
+
const missingMetadata = [];
|
|
2892
|
+
for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
|
|
2893
|
+
const ruleName = fullKey.replace(/^react-doctor\//, "");
|
|
2894
|
+
if (!getRuleCategory(ruleName)) missingCategory.push(fullKey);
|
|
2895
|
+
if (!getRuleRecommendation(ruleName)) missingHelp.push(fullKey);
|
|
2896
|
+
if (FRAMEWORK_SPECIFIC_RULE_KEYS.has(fullKey) && !reactDoctorPlugin.rules[ruleName]?.requires) missingMetadata.push(fullKey);
|
|
2897
|
+
}
|
|
2898
|
+
if (missingCategory.length > 0 || missingHelp.length > 0 || missingMetadata.length > 0) {
|
|
2899
|
+
const detail = [
|
|
2900
|
+
missingCategory.length > 0 ? `Missing rule categories (add to defineRule call): ${missingCategory.join(", ")}` : null,
|
|
2901
|
+
missingHelp.length > 0 ? `Missing rule recommendations (add to defineRule call): ${missingHelp.join(", ")}` : null,
|
|
2902
|
+
missingMetadata.length > 0 ? `Missing rule \`requires\` capability gate (add to defineRule call): ${missingMetadata.join(", ")}` : null
|
|
2903
|
+
].filter((entry) => entry !== null).join("; ");
|
|
2904
|
+
console.warn(`[react-doctor] rule-registration drift: ${detail}`);
|
|
2905
|
+
}
|
|
2906
|
+
};
|
|
2907
|
+
const runOxlint = async (options) => {
|
|
2908
|
+
const { rootDirectory, project, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true, ignoredTags = /* @__PURE__ */ new Set(), onPartialFailure } = options;
|
|
2909
|
+
validateRuleRegistration();
|
|
2910
|
+
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
2911
|
+
const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
|
|
2912
|
+
const configPath = path.join(configDirectory, "oxlintrc.json");
|
|
2913
|
+
const pluginPath = resolvePluginPath();
|
|
2914
|
+
const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
|
|
2915
|
+
const config = createOxlintConfig({
|
|
2916
|
+
pluginPath,
|
|
2917
|
+
project,
|
|
2918
|
+
customRulesOnly,
|
|
2919
|
+
extendsPaths,
|
|
2920
|
+
ignoredTags
|
|
2921
|
+
});
|
|
2922
|
+
const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
|
|
2923
|
+
try {
|
|
2924
|
+
const baseArgs = [
|
|
2925
|
+
resolveOxlintBinary(),
|
|
2926
|
+
"-c",
|
|
2927
|
+
configPath,
|
|
2928
|
+
"--format",
|
|
2929
|
+
"json"
|
|
2930
|
+
];
|
|
2931
|
+
if (project.hasTypeScript) {
|
|
2932
|
+
const tsconfigRelativePath = resolveTsConfigRelativePath(rootDirectory);
|
|
2933
|
+
if (tsconfigRelativePath) baseArgs.push("--tsconfig", tsconfigRelativePath);
|
|
2934
|
+
}
|
|
2935
|
+
const combinedPatterns = collectIgnorePatterns(rootDirectory);
|
|
2936
|
+
if (combinedPatterns.length > 0) {
|
|
2937
|
+
const combinedIgnorePath = path.join(configDirectory, "combined.ignore");
|
|
2938
|
+
fs.writeFileSync(combinedIgnorePath, `${combinedPatterns.join("\n")}\n`);
|
|
2939
|
+
baseArgs.push("--ignore-path", combinedIgnorePath);
|
|
2940
|
+
}
|
|
2941
|
+
const fileBatches = batchIncludePaths(baseArgs, includePaths !== void 0 ? includePaths : listSourceFiles(rootDirectory));
|
|
2942
|
+
const writeOxlintConfig = (configToWrite) => {
|
|
2943
|
+
fs.rmSync(configPath, { force: true });
|
|
2944
|
+
const fileHandle = fs.openSync(configPath, "wx", 384);
|
|
2945
|
+
try {
|
|
2946
|
+
fs.writeFileSync(fileHandle, JSON.stringify(configToWrite));
|
|
2947
|
+
} finally {
|
|
2948
|
+
fs.closeSync(fileHandle);
|
|
2949
|
+
}
|
|
2950
|
+
};
|
|
2951
|
+
const spawnLintBatches = async () => {
|
|
2952
|
+
const allDiagnostics = [];
|
|
2953
|
+
const droppedFiles = [];
|
|
2954
|
+
const spawnLintBatch = async (batch) => {
|
|
2955
|
+
const batchArgs = [...baseArgs, ...batch];
|
|
2956
|
+
try {
|
|
2957
|
+
return parseOxlintOutput(await spawnOxlint(batchArgs, rootDirectory, nodeBinaryPath), rootDirectory);
|
|
2958
|
+
} catch (error) {
|
|
2959
|
+
if (!isSplittableOxlintBatchError(error)) throw error;
|
|
2960
|
+
if (batch.length <= 1) {
|
|
2961
|
+
droppedFiles.push(...batch);
|
|
2962
|
+
return [];
|
|
2963
|
+
}
|
|
2964
|
+
const splitIndex = Math.ceil(batch.length / 2);
|
|
2965
|
+
return [...await spawnLintBatch(batch.slice(0, splitIndex)), ...await spawnLintBatch(batch.slice(splitIndex))];
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
for (const batch of fileBatches) allDiagnostics.push(...await spawnLintBatch(batch));
|
|
2969
|
+
if (droppedFiles.length > 0 && onPartialFailure) {
|
|
2970
|
+
const previewCount = 3;
|
|
2971
|
+
const previewFiles = droppedFiles.slice(0, previewCount).join(", ");
|
|
2972
|
+
const remainderHint = droppedFiles.length > previewCount ? `, +${droppedFiles.length - previewCount} more` : "";
|
|
2973
|
+
onPartialFailure(`${droppedFiles.length} file(s) exceeded the ${OXLINT_SPAWN_TIMEOUT_MS / 1e3}s per-batch oxlint budget and were skipped (${previewFiles}${remainderHint})`);
|
|
2974
|
+
}
|
|
2975
|
+
return dedupeDiagnostics(allDiagnostics);
|
|
2976
|
+
};
|
|
2977
|
+
writeOxlintConfig(config);
|
|
2978
|
+
try {
|
|
2979
|
+
return await spawnLintBatches();
|
|
2980
|
+
} catch (error) {
|
|
2981
|
+
if (extendsPaths.length === 0) throw error;
|
|
2982
|
+
writeOxlintConfig(createOxlintConfig({
|
|
2983
|
+
pluginPath,
|
|
2984
|
+
project,
|
|
2985
|
+
customRulesOnly,
|
|
2986
|
+
extendsPaths: [],
|
|
2987
|
+
ignoredTags
|
|
2988
|
+
}));
|
|
2989
|
+
return await spawnLintBatches();
|
|
2990
|
+
}
|
|
2991
|
+
} finally {
|
|
2992
|
+
restoreDisableDirectives();
|
|
2993
|
+
fs.rmSync(configDirectory, {
|
|
2994
|
+
recursive: true,
|
|
2995
|
+
force: true
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
};
|
|
2999
|
+
//#endregion
|
|
3000
|
+
//#region src/index.ts
|
|
3001
|
+
const clearCaches = () => {
|
|
3002
|
+
clearProjectCache();
|
|
3003
|
+
clearConfigCache();
|
|
3004
|
+
clearPackageJsonCache();
|
|
3005
|
+
clearIgnorePatternsCache();
|
|
3006
|
+
clearAutoSuppressionCaches();
|
|
3007
|
+
};
|
|
3008
|
+
const toJsonReport = (result, options) => buildJsonReport({
|
|
3009
|
+
version: options.version,
|
|
3010
|
+
directory: options.directory ?? result.project.rootDirectory,
|
|
3011
|
+
mode: options.mode ?? "full",
|
|
3012
|
+
diff: null,
|
|
3013
|
+
scans: [{
|
|
3014
|
+
directory: result.project.rootDirectory,
|
|
3015
|
+
result: {
|
|
3016
|
+
diagnostics: result.diagnostics,
|
|
3017
|
+
score: result.score,
|
|
3018
|
+
skippedChecks: [],
|
|
3019
|
+
project: result.project,
|
|
3020
|
+
elapsedMilliseconds: result.elapsedMilliseconds
|
|
3021
|
+
}
|
|
3022
|
+
}],
|
|
3023
|
+
totalElapsedMilliseconds: result.elapsedMilliseconds
|
|
3024
|
+
});
|
|
3025
|
+
const EMPTY_DIAGNOSTICS = [];
|
|
3026
|
+
const diagnose = async (directory, options = {}) => {
|
|
3027
|
+
const startTime = globalThis.performance.now();
|
|
3028
|
+
const requestedDirectory = path.resolve(directory);
|
|
3029
|
+
const initialLoadedConfig = loadConfigWithSource(requestedDirectory);
|
|
3030
|
+
const directoryAfterRedirect = resolveConfigRootDir(initialLoadedConfig?.config ?? null, initialLoadedConfig?.sourceDirectory ?? null) ?? requestedDirectory;
|
|
3031
|
+
const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect);
|
|
3032
|
+
if (!resolvedDirectory) throw new ProjectNotFoundError(directoryAfterRedirect);
|
|
3033
|
+
const userConfig = initialLoadedConfig?.config ?? loadConfigWithSource(resolvedDirectory)?.config ?? null;
|
|
3034
|
+
const includePaths = options.includePaths ?? [];
|
|
3035
|
+
const isDiffMode = includePaths.length > 0;
|
|
3036
|
+
const projectInfo = discoverProject(resolvedDirectory);
|
|
3037
|
+
if (!projectInfo.reactVersion) throw new NoReactDependencyError(resolvedDirectory);
|
|
3038
|
+
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
|
|
3039
|
+
const readFileLinesSync = createNodeReadFileLinesSync(resolvedDirectory);
|
|
3040
|
+
const effectiveLint = options.lint ?? userConfig?.lint ?? true;
|
|
3041
|
+
const effectiveRespectInlineDisables = options.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true;
|
|
3042
|
+
const ignoredTags = new Set(userConfig?.ignore?.tags ?? []);
|
|
3043
|
+
const diagnostics = combineDiagnostics({
|
|
3044
|
+
lintDiagnostics: effectiveLint ? await runOxlint({
|
|
3045
|
+
rootDirectory: resolvedDirectory,
|
|
3046
|
+
project: projectInfo,
|
|
3047
|
+
includePaths: lintIncludePaths,
|
|
3048
|
+
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
3049
|
+
respectInlineDisables: effectiveRespectInlineDisables,
|
|
3050
|
+
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true,
|
|
3051
|
+
ignoredTags
|
|
3052
|
+
}).catch((error) => {
|
|
3053
|
+
console.error("Lint failed:", error);
|
|
3054
|
+
return EMPTY_DIAGNOSTICS;
|
|
3055
|
+
}) : EMPTY_DIAGNOSTICS,
|
|
3056
|
+
directory: resolvedDirectory,
|
|
3057
|
+
isDiffMode,
|
|
3058
|
+
userConfig,
|
|
3059
|
+
readFileLinesSync,
|
|
3060
|
+
respectInlineDisables: effectiveRespectInlineDisables
|
|
3061
|
+
});
|
|
3062
|
+
const elapsedMilliseconds = globalThis.performance.now() - startTime;
|
|
3063
|
+
return {
|
|
3064
|
+
diagnostics,
|
|
3065
|
+
score: await calculateScore(diagnostics),
|
|
3066
|
+
project: projectInfo,
|
|
3067
|
+
elapsedMilliseconds
|
|
3068
|
+
};
|
|
3069
|
+
};
|
|
3070
|
+
//#endregion
|
|
3071
|
+
export { AmbiguousProjectError, NoReactDependencyError, PackageJsonNotFoundError, ProjectNotFoundError, ReactDoctorError, buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, isReactDoctorError, summarizeDiagnostics, toJsonReport };
|
|
3072
|
+
|
|
3073
|
+
//# sourceMappingURL=index.js.map
|