react-doctor 0.0.46 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -238
- package/dist/cli.js +824 -114
- package/dist/eslint-plugin.d.ts +57 -0
- package/dist/eslint-plugin.js +6965 -0
- package/dist/index.d.ts +33 -2
- package/dist/index.js +940 -400
- package/dist/react-doctor-plugin.js +2010 -320
- package/package.json +9 -13
- package/dist/browser-BOxs7MrK.js +0 -359
- package/dist/browser-Dcq3yn-p.d.ts +0 -146
- package/dist/browser.d.ts +0 -2
- package/dist/browser.js +0 -2
- package/dist/worker.d.ts +0 -2
- package/dist/worker.js +0 -2
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ const KNIP_CONFIG_LOCATIONS = [
|
|
|
26
26
|
"knip.config.ts",
|
|
27
27
|
"knip.config.js"
|
|
28
28
|
];
|
|
29
|
+
const ADOPTABLE_LINT_CONFIG_FILENAMES = [".oxlintrc.json", ".eslintrc.json"];
|
|
29
30
|
const IGNORED_DIRECTORIES = new Set([
|
|
30
31
|
"node_modules",
|
|
31
32
|
"dist",
|
|
@@ -35,197 +36,6 @@ const IGNORED_DIRECTORIES = new Set([
|
|
|
35
36
|
const PROXY_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
36
37
|
const buildNoReactDependencyError = (directory) => `No React dependency found in ${directory}/package.json. Add "react" to dependencies (or peerDependencies) and re-run.`;
|
|
37
38
|
//#endregion
|
|
38
|
-
//#region src/utils/match-glob-pattern.ts
|
|
39
|
-
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
40
|
-
const compileGlobPattern = (pattern) => {
|
|
41
|
-
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
|
|
42
|
-
let regexSource = "^";
|
|
43
|
-
let characterIndex = 0;
|
|
44
|
-
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
45
|
-
regexSource += "(?:.+/)?";
|
|
46
|
-
characterIndex += 3;
|
|
47
|
-
} else {
|
|
48
|
-
regexSource += ".*";
|
|
49
|
-
characterIndex += 2;
|
|
50
|
-
}
|
|
51
|
-
else if (normalizedPattern[characterIndex] === "*") {
|
|
52
|
-
regexSource += "[^/]*";
|
|
53
|
-
characterIndex++;
|
|
54
|
-
} else if (normalizedPattern[characterIndex] === "?") {
|
|
55
|
-
regexSource += "[^/]";
|
|
56
|
-
characterIndex++;
|
|
57
|
-
} else {
|
|
58
|
-
regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
|
|
59
|
-
characterIndex++;
|
|
60
|
-
}
|
|
61
|
-
regexSource += "$";
|
|
62
|
-
return new RegExp(regexSource);
|
|
63
|
-
};
|
|
64
|
-
//#endregion
|
|
65
|
-
//#region src/utils/is-ignored-file.ts
|
|
66
|
-
const toRelativePath = (filePath, rootDirectory) => {
|
|
67
|
-
const normalizedFilePath = filePath.replace(/\\/g, "/");
|
|
68
|
-
const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
|
|
69
|
-
if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
|
|
70
|
-
return normalizedFilePath.replace(/^\.\//, "");
|
|
71
|
-
};
|
|
72
|
-
const compileIgnoredFilePatterns = (userConfig) => {
|
|
73
|
-
const files = userConfig?.ignore?.files;
|
|
74
|
-
if (!Array.isArray(files)) return [];
|
|
75
|
-
return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
|
|
76
|
-
};
|
|
77
|
-
const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
|
|
78
|
-
if (patterns.length === 0) return false;
|
|
79
|
-
const relativePath = toRelativePath(filePath, rootDirectory);
|
|
80
|
-
return patterns.some((pattern) => pattern.test(relativePath));
|
|
81
|
-
};
|
|
82
|
-
//#endregion
|
|
83
|
-
//#region src/utils/filter-diagnostics.ts
|
|
84
|
-
const resolveCandidateReadPath = (rootDirectory, filePath) => {
|
|
85
|
-
const normalizedFile = filePath.replace(/\\/g, "/");
|
|
86
|
-
if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
|
|
87
|
-
return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
|
|
88
|
-
};
|
|
89
|
-
const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
|
|
90
|
-
const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
|
|
91
|
-
const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
|
|
92
|
-
const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
|
|
93
|
-
const cache = /* @__PURE__ */ new Map();
|
|
94
|
-
return (filePath) => {
|
|
95
|
-
const cached = cache.get(filePath);
|
|
96
|
-
if (cached !== void 0) return cached;
|
|
97
|
-
const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
|
|
98
|
-
cache.set(filePath, lines);
|
|
99
|
-
return lines;
|
|
100
|
-
};
|
|
101
|
-
};
|
|
102
|
-
const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
|
|
103
|
-
for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
|
|
104
|
-
const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
|
|
105
|
-
if (!match) continue;
|
|
106
|
-
const fullTagName = match[1];
|
|
107
|
-
const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
|
|
108
|
-
return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
|
|
109
|
-
}
|
|
110
|
-
return false;
|
|
111
|
-
};
|
|
112
|
-
const isRuleSuppressed = (commentRules, ruleId) => {
|
|
113
|
-
if (!commentRules?.trim()) return true;
|
|
114
|
-
return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
|
|
115
|
-
};
|
|
116
|
-
const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
|
|
117
|
-
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
|
|
118
|
-
const ignoredFilePatterns = compileIgnoredFilePatterns(config);
|
|
119
|
-
const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
|
|
120
|
-
const hasTextComponents = textComponentNames.size > 0;
|
|
121
|
-
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
122
|
-
return diagnostics.filter((diagnostic) => {
|
|
123
|
-
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
124
|
-
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
125
|
-
if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
|
|
126
|
-
if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
|
|
127
|
-
const lines = getFileLines(diagnostic.filePath);
|
|
128
|
-
if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
|
|
129
|
-
}
|
|
130
|
-
return true;
|
|
131
|
-
});
|
|
132
|
-
};
|
|
133
|
-
const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
|
|
134
|
-
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
135
|
-
return diagnostics.filter((diagnostic) => {
|
|
136
|
-
if (diagnostic.line <= 0) return true;
|
|
137
|
-
const lines = getFileLines(diagnostic.filePath);
|
|
138
|
-
if (!lines) return true;
|
|
139
|
-
const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
140
|
-
const currentLine = lines[diagnostic.line - 1];
|
|
141
|
-
if (currentLine) {
|
|
142
|
-
const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
|
|
143
|
-
if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
|
|
144
|
-
}
|
|
145
|
-
if (diagnostic.line >= 2) {
|
|
146
|
-
const previousLine = lines[diagnostic.line - 2];
|
|
147
|
-
if (previousLine) {
|
|
148
|
-
const nextLineMatch = previousLine.match(DISABLE_NEXT_LINE_PATTERN);
|
|
149
|
-
if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return true;
|
|
153
|
-
});
|
|
154
|
-
};
|
|
155
|
-
//#endregion
|
|
156
|
-
//#region src/utils/merge-and-filter-diagnostics.ts
|
|
157
|
-
const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync) => {
|
|
158
|
-
return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
|
|
159
|
-
};
|
|
160
|
-
//#endregion
|
|
161
|
-
//#region src/core/build-result.ts
|
|
162
|
-
const buildDiagnoseTimedResult = async (input) => {
|
|
163
|
-
const diagnostics = mergeAndFilterDiagnostics(input.mergedDiagnostics, input.rootDirectory, input.userConfig, input.readFileLinesSync);
|
|
164
|
-
const elapsedMilliseconds = globalThis.performance.now() - input.startTime;
|
|
165
|
-
return {
|
|
166
|
-
diagnostics,
|
|
167
|
-
score: input.score !== void 0 ? input.score : await input.calculateDiagnosticsScore(diagnostics),
|
|
168
|
-
elapsedMilliseconds
|
|
169
|
-
};
|
|
170
|
-
};
|
|
171
|
-
//#endregion
|
|
172
|
-
//#region src/utils/jsx-include-paths.ts
|
|
173
|
-
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
174
|
-
//#endregion
|
|
175
|
-
//#region src/core/diagnose-core.ts
|
|
176
|
-
const diagnoseCore = async (deps, options = {}) => {
|
|
177
|
-
const { includePaths = [] } = options;
|
|
178
|
-
const isDiffMode = includePaths.length > 0;
|
|
179
|
-
const startTime = globalThis.performance.now();
|
|
180
|
-
const resolvedDirectory = deps.rootDirectory;
|
|
181
|
-
const projectInfo = deps.discoverProjectInfo();
|
|
182
|
-
const userConfig = deps.loadUserConfig();
|
|
183
|
-
const effectiveLint = options.lint ?? userConfig?.lint ?? true;
|
|
184
|
-
const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
|
|
185
|
-
if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(deps.rootDirectory));
|
|
186
|
-
const lintIncludePaths = options.lintIncludePaths !== void 0 ? options.lintIncludePaths : computeJsxIncludePaths(includePaths);
|
|
187
|
-
const { runLint, runDeadCode } = deps.createRunners({
|
|
188
|
-
resolvedDirectory,
|
|
189
|
-
projectInfo,
|
|
190
|
-
userConfig,
|
|
191
|
-
lintIncludePaths,
|
|
192
|
-
isDiffMode
|
|
193
|
-
});
|
|
194
|
-
const emptyDiagnostics = [];
|
|
195
|
-
const lintPromise = effectiveLint ? runLint().catch((error) => {
|
|
196
|
-
console.error("Lint failed:", error);
|
|
197
|
-
return emptyDiagnostics;
|
|
198
|
-
}) : Promise.resolve(emptyDiagnostics);
|
|
199
|
-
const deadCodePromise = effectiveDeadCode && !isDiffMode ? runDeadCode().catch((error) => {
|
|
200
|
-
console.error("Dead code analysis failed:", error);
|
|
201
|
-
return emptyDiagnostics;
|
|
202
|
-
}) : Promise.resolve(emptyDiagnostics);
|
|
203
|
-
const [lintSettled, deadCodeSettled] = await Promise.allSettled([lintPromise, deadCodePromise]);
|
|
204
|
-
const lintDiagnostics = lintSettled.status === "fulfilled" ? lintSettled.value : emptyDiagnostics;
|
|
205
|
-
const deadCodeDiagnostics = deadCodeSettled.status === "fulfilled" ? deadCodeSettled.value : emptyDiagnostics;
|
|
206
|
-
if (lintSettled.status === "rejected") console.error("Lint rejected:", lintSettled.reason);
|
|
207
|
-
if (deadCodeSettled.status === "rejected") console.error("Dead code rejected:", deadCodeSettled.reason);
|
|
208
|
-
const environmentDiagnostics = deps.getExtraDiagnostics?.() ?? [];
|
|
209
|
-
const timed = await buildDiagnoseTimedResult({
|
|
210
|
-
mergedDiagnostics: [
|
|
211
|
-
...lintDiagnostics,
|
|
212
|
-
...deadCodeDiagnostics,
|
|
213
|
-
...environmentDiagnostics
|
|
214
|
-
],
|
|
215
|
-
rootDirectory: resolvedDirectory,
|
|
216
|
-
userConfig,
|
|
217
|
-
readFileLinesSync: deps.readFileLinesSync,
|
|
218
|
-
startTime,
|
|
219
|
-
calculateDiagnosticsScore: deps.calculateDiagnosticsScore
|
|
220
|
-
});
|
|
221
|
-
return {
|
|
222
|
-
diagnostics: timed.diagnostics,
|
|
223
|
-
score: timed.score,
|
|
224
|
-
project: projectInfo,
|
|
225
|
-
elapsedMilliseconds: timed.elapsedMilliseconds
|
|
226
|
-
};
|
|
227
|
-
};
|
|
228
|
-
//#endregion
|
|
229
39
|
//#region src/utils/summarize-diagnostics.ts
|
|
230
40
|
const summarizeDiagnostics = (diagnostics, worstScore = null, worstScoreLabel = null) => {
|
|
231
41
|
let errorCount = 0;
|
|
@@ -359,7 +169,192 @@ const buildJsonReportError = (input) => {
|
|
|
359
169
|
};
|
|
360
170
|
};
|
|
361
171
|
//#endregion
|
|
172
|
+
//#region src/utils/calculate-score-locally.ts
|
|
173
|
+
const getScoreLabel = (score) => {
|
|
174
|
+
if (score >= 75) return "Great";
|
|
175
|
+
if (score >= 50) return "Needs work";
|
|
176
|
+
return "Critical";
|
|
177
|
+
};
|
|
178
|
+
const countUniqueRules = (diagnostics) => {
|
|
179
|
+
const errorRules = /* @__PURE__ */ new Set();
|
|
180
|
+
const warningRules = /* @__PURE__ */ new Set();
|
|
181
|
+
for (const diagnostic of diagnostics) {
|
|
182
|
+
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
183
|
+
if (diagnostic.severity === "error") errorRules.add(ruleKey);
|
|
184
|
+
else warningRules.add(ruleKey);
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
errorRuleCount: errorRules.size,
|
|
188
|
+
warningRuleCount: warningRules.size
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
|
|
192
|
+
const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
|
|
193
|
+
return Math.max(0, Math.round(100 - penalty));
|
|
194
|
+
};
|
|
195
|
+
const calculateScoreLocally = (diagnostics) => {
|
|
196
|
+
const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
|
|
197
|
+
const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
|
|
198
|
+
return {
|
|
199
|
+
score,
|
|
200
|
+
label: getScoreLabel(score)
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/utils/try-score-from-api.ts
|
|
205
|
+
const parseScoreResult = (value) => {
|
|
206
|
+
if (typeof value !== "object" || value === null) return null;
|
|
207
|
+
if (!("score" in value) || !("label" in value)) return null;
|
|
208
|
+
const scoreValue = Reflect.get(value, "score");
|
|
209
|
+
const labelValue = Reflect.get(value, "label");
|
|
210
|
+
if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
|
|
211
|
+
return {
|
|
212
|
+
score: scoreValue,
|
|
213
|
+
label: labelValue
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
|
|
217
|
+
const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
218
|
+
const describeFailure = (error) => {
|
|
219
|
+
if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
|
|
220
|
+
if (error instanceof Error && error.message) return error.message;
|
|
221
|
+
return String(error);
|
|
222
|
+
};
|
|
223
|
+
const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
|
|
224
|
+
if (typeof fetchImplementation !== "function") return null;
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetchImplementation(SCORE_API_URL, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "Content-Type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
|
|
232
|
+
signal: controller.signal
|
|
233
|
+
});
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
return parseScoreResult(await response.json());
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
|
|
241
|
+
return null;
|
|
242
|
+
} finally {
|
|
243
|
+
clearTimeout(timeoutId);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/utils/proxy-fetch.ts
|
|
248
|
+
const getGlobalProcess = () => {
|
|
249
|
+
const candidate = globalThis.process;
|
|
250
|
+
return candidate?.versions?.node ? candidate : void 0;
|
|
251
|
+
};
|
|
252
|
+
const getProxyUrl = () => {
|
|
253
|
+
const proc = getGlobalProcess();
|
|
254
|
+
if (!proc?.env) return void 0;
|
|
255
|
+
return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
|
|
256
|
+
};
|
|
257
|
+
const createProxyDispatcher = async (proxyUrl) => {
|
|
258
|
+
try {
|
|
259
|
+
const { ProxyAgent } = await import("undici");
|
|
260
|
+
return new ProxyAgent(proxyUrl);
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const proxyFetch = async (url, init) => {
|
|
266
|
+
const proxyUrl = getProxyUrl();
|
|
267
|
+
const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
|
|
268
|
+
const fetchInit = {
|
|
269
|
+
...init,
|
|
270
|
+
...dispatcher ? { dispatcher } : {}
|
|
271
|
+
};
|
|
272
|
+
return fetch(url, fetchInit);
|
|
273
|
+
};
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region src/utils/calculate-score.ts
|
|
276
|
+
const calculateScore = async (diagnostics) => await tryScoreFromApi(diagnostics, proxyFetch) ?? calculateScoreLocally(diagnostics);
|
|
277
|
+
//#endregion
|
|
362
278
|
//#region src/plugin/constants.ts
|
|
279
|
+
const FETCH_CALLEE_NAMES = new Set([
|
|
280
|
+
"fetch",
|
|
281
|
+
"ky",
|
|
282
|
+
"got",
|
|
283
|
+
"wretch",
|
|
284
|
+
"ofetch"
|
|
285
|
+
]);
|
|
286
|
+
const FETCH_MEMBER_OBJECTS = new Set([
|
|
287
|
+
"axios",
|
|
288
|
+
"ky",
|
|
289
|
+
"got",
|
|
290
|
+
"ofetch",
|
|
291
|
+
"wretch",
|
|
292
|
+
"request"
|
|
293
|
+
]);
|
|
294
|
+
const TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES = new Set([
|
|
295
|
+
"setTimeout",
|
|
296
|
+
"setInterval",
|
|
297
|
+
"requestAnimationFrame",
|
|
298
|
+
"requestIdleCallback",
|
|
299
|
+
"queueMicrotask"
|
|
300
|
+
]);
|
|
301
|
+
const SUBSCRIPTION_METHOD_NAMES = new Set([
|
|
302
|
+
"subscribe",
|
|
303
|
+
"addEventListener",
|
|
304
|
+
"addListener",
|
|
305
|
+
"on",
|
|
306
|
+
"watch",
|
|
307
|
+
"listen",
|
|
308
|
+
"sub"
|
|
309
|
+
]);
|
|
310
|
+
new Set([
|
|
311
|
+
...new Set([
|
|
312
|
+
"unsubscribe",
|
|
313
|
+
"removeEventListener",
|
|
314
|
+
"removeListener",
|
|
315
|
+
"off",
|
|
316
|
+
"unwatch",
|
|
317
|
+
"unlisten",
|
|
318
|
+
"unsub"
|
|
319
|
+
]),
|
|
320
|
+
"cleanup",
|
|
321
|
+
"dispose",
|
|
322
|
+
"destroy",
|
|
323
|
+
"teardown"
|
|
324
|
+
]);
|
|
325
|
+
new Set([
|
|
326
|
+
...SUBSCRIPTION_METHOD_NAMES,
|
|
327
|
+
"connect",
|
|
328
|
+
"disconnect",
|
|
329
|
+
"open",
|
|
330
|
+
"close",
|
|
331
|
+
"fetch",
|
|
332
|
+
"post",
|
|
333
|
+
"put",
|
|
334
|
+
"patch"
|
|
335
|
+
]);
|
|
336
|
+
new Set([
|
|
337
|
+
...FETCH_MEMBER_OBJECTS,
|
|
338
|
+
"api",
|
|
339
|
+
"client",
|
|
340
|
+
"http",
|
|
341
|
+
"fetcher"
|
|
342
|
+
]);
|
|
343
|
+
new Set([...FETCH_CALLEE_NAMES, ...TIMER_AND_SCHEDULER_DIRECT_CALLEE_NAMES]);
|
|
344
|
+
new Set([
|
|
345
|
+
...FETCH_CALLEE_NAMES,
|
|
346
|
+
"post",
|
|
347
|
+
"put",
|
|
348
|
+
"patch",
|
|
349
|
+
"navigate",
|
|
350
|
+
"navigateTo",
|
|
351
|
+
"showNotification",
|
|
352
|
+
"toast",
|
|
353
|
+
"alert",
|
|
354
|
+
"confirm",
|
|
355
|
+
"logVisit",
|
|
356
|
+
"captureEvent"
|
|
357
|
+
]);
|
|
363
358
|
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
364
359
|
//#endregion
|
|
365
360
|
//#region src/utils/is-file.ts
|
|
@@ -915,7 +910,7 @@ const hasCompilerInConfigFile = (filePath) => {
|
|
|
915
910
|
return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
|
|
916
911
|
};
|
|
917
912
|
const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
|
|
918
|
-
const isProjectBoundary$
|
|
913
|
+
const isProjectBoundary$2 = (directory) => {
|
|
919
914
|
if (fs.existsSync(path.join(directory, ".git"))) return true;
|
|
920
915
|
return isMonorepoRoot(directory);
|
|
921
916
|
};
|
|
@@ -925,14 +920,14 @@ const detectReactCompiler = (directory, packageJson) => {
|
|
|
925
920
|
if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
|
|
926
921
|
if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
|
|
927
922
|
if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
|
|
928
|
-
if (isProjectBoundary$
|
|
923
|
+
if (isProjectBoundary$2(directory)) return false;
|
|
929
924
|
let ancestorDirectory = path.dirname(directory);
|
|
930
925
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
931
926
|
const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
|
|
932
927
|
if (isFile(ancestorPackagePath)) {
|
|
933
928
|
if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
|
|
934
929
|
}
|
|
935
|
-
if (isProjectBoundary$
|
|
930
|
+
if (isProjectBoundary$2(ancestorDirectory)) return false;
|
|
936
931
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
937
932
|
}
|
|
938
933
|
return false;
|
|
@@ -986,6 +981,9 @@ const discoverProject = (directory) => {
|
|
|
986
981
|
return projectInfo;
|
|
987
982
|
};
|
|
988
983
|
//#endregion
|
|
984
|
+
//#region src/utils/jsx-include-paths.ts
|
|
985
|
+
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
986
|
+
//#endregion
|
|
989
987
|
//#region src/utils/validate-config-types.ts
|
|
990
988
|
const BOOLEAN_FIELD_NAMES = [
|
|
991
989
|
"lint",
|
|
@@ -993,22 +991,23 @@ const BOOLEAN_FIELD_NAMES = [
|
|
|
993
991
|
"verbose",
|
|
994
992
|
"customRulesOnly",
|
|
995
993
|
"share",
|
|
996
|
-
"respectInlineDisables"
|
|
994
|
+
"respectInlineDisables",
|
|
995
|
+
"adoptExistingLintConfig"
|
|
997
996
|
];
|
|
998
|
-
const warnConfigField = (message) => {
|
|
997
|
+
const warnConfigField$1 = (message) => {
|
|
999
998
|
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1000
999
|
};
|
|
1001
1000
|
const coerceMaybeBooleanString = (fieldName, value) => {
|
|
1002
1001
|
if (typeof value === "boolean" || value === void 0) return value;
|
|
1003
1002
|
if (value === "true") {
|
|
1004
|
-
warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
|
|
1003
|
+
warnConfigField$1(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
|
|
1005
1004
|
return true;
|
|
1006
1005
|
}
|
|
1007
1006
|
if (value === "false") {
|
|
1008
|
-
warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
|
|
1007
|
+
warnConfigField$1(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
|
|
1009
1008
|
return false;
|
|
1010
1009
|
}
|
|
1011
|
-
warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1010
|
+
warnConfigField$1(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1012
1011
|
};
|
|
1013
1012
|
const validateConfigTypes = (config) => {
|
|
1014
1013
|
const validated = { ...config };
|
|
@@ -1048,38 +1047,383 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1048
1047
|
}
|
|
1049
1048
|
return null;
|
|
1050
1049
|
};
|
|
1051
|
-
const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1050
|
+
const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1052
1051
|
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
1053
1052
|
const clearConfigCache = () => {
|
|
1054
1053
|
cachedConfigs.clear();
|
|
1055
1054
|
};
|
|
1056
|
-
const loadConfig = (rootDirectory) => {
|
|
1057
|
-
const cached = cachedConfigs.get(rootDirectory);
|
|
1058
|
-
if (cached !== void 0) return cached;
|
|
1059
|
-
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
1060
|
-
if (localConfig) {
|
|
1061
|
-
cachedConfigs.set(rootDirectory, localConfig);
|
|
1062
|
-
return localConfig;
|
|
1063
|
-
}
|
|
1064
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
1065
|
-
cachedConfigs.set(rootDirectory, null);
|
|
1066
|
-
return null;
|
|
1055
|
+
const loadConfig = (rootDirectory) => {
|
|
1056
|
+
const cached = cachedConfigs.get(rootDirectory);
|
|
1057
|
+
if (cached !== void 0) return cached;
|
|
1058
|
+
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
1059
|
+
if (localConfig) {
|
|
1060
|
+
cachedConfigs.set(rootDirectory, localConfig);
|
|
1061
|
+
return localConfig;
|
|
1062
|
+
}
|
|
1063
|
+
if (isProjectBoundary$1(rootDirectory)) {
|
|
1064
|
+
cachedConfigs.set(rootDirectory, null);
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
let ancestorDirectory = path.dirname(rootDirectory);
|
|
1068
|
+
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
1069
|
+
const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
|
|
1070
|
+
if (ancestorConfig) {
|
|
1071
|
+
cachedConfigs.set(rootDirectory, ancestorConfig);
|
|
1072
|
+
return ancestorConfig;
|
|
1073
|
+
}
|
|
1074
|
+
if (isProjectBoundary$1(ancestorDirectory)) {
|
|
1075
|
+
cachedConfigs.set(rootDirectory, null);
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
1079
|
+
}
|
|
1080
|
+
cachedConfigs.set(rootDirectory, null);
|
|
1081
|
+
return null;
|
|
1082
|
+
};
|
|
1083
|
+
//#endregion
|
|
1084
|
+
//#region src/utils/match-glob-pattern.ts
|
|
1085
|
+
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
1086
|
+
const compileGlobPattern = (pattern) => {
|
|
1087
|
+
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
|
|
1088
|
+
let regexSource = "^";
|
|
1089
|
+
let characterIndex = 0;
|
|
1090
|
+
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
1091
|
+
regexSource += "(?:.+/)?";
|
|
1092
|
+
characterIndex += 3;
|
|
1093
|
+
} else {
|
|
1094
|
+
regexSource += ".*";
|
|
1095
|
+
characterIndex += 2;
|
|
1096
|
+
}
|
|
1097
|
+
else if (normalizedPattern[characterIndex] === "*") {
|
|
1098
|
+
regexSource += "[^/]*";
|
|
1099
|
+
characterIndex++;
|
|
1100
|
+
} else if (normalizedPattern[characterIndex] === "?") {
|
|
1101
|
+
regexSource += "[^/]";
|
|
1102
|
+
characterIndex++;
|
|
1103
|
+
} else {
|
|
1104
|
+
regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
|
|
1105
|
+
characterIndex++;
|
|
1106
|
+
}
|
|
1107
|
+
regexSource += "$";
|
|
1108
|
+
return new RegExp(regexSource);
|
|
1109
|
+
};
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/utils/to-relative-path.ts
|
|
1112
|
+
const toRelativePath = (filePath, rootDirectory) => {
|
|
1113
|
+
const normalizedFilePath = filePath.replace(/\\/g, "/");
|
|
1114
|
+
const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
|
|
1115
|
+
if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
|
|
1116
|
+
return normalizedFilePath.replace(/^\.\//, "");
|
|
1117
|
+
};
|
|
1118
|
+
//#endregion
|
|
1119
|
+
//#region src/utils/apply-ignore-overrides.ts
|
|
1120
|
+
const warnConfigField = (message) => {
|
|
1121
|
+
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1122
|
+
};
|
|
1123
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
1124
|
+
const collectStringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1125
|
+
const validateOverrideEntry = (entry, index) => {
|
|
1126
|
+
if (!isPlainObject(entry)) {
|
|
1127
|
+
warnConfigField(`ignore.overrides[${index}] must be an object with { files, rules }; ignoring this entry.`);
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
if (!isStringArray(entry.files)) {
|
|
1131
|
+
warnConfigField(`ignore.overrides[${index}].files must be an array of strings; ignoring this entry.`);
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
if (entry.rules !== void 0 && !isStringArray(entry.rules)) {
|
|
1135
|
+
warnConfigField(`ignore.overrides[${index}].rules must be an array of "plugin/rule" strings or omitted; treating as missing (override would suppress every rule for the matched files).`);
|
|
1136
|
+
return { files: entry.files };
|
|
1137
|
+
}
|
|
1138
|
+
return entry.rules === void 0 ? { files: entry.files } : {
|
|
1139
|
+
files: entry.files,
|
|
1140
|
+
rules: entry.rules
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
const compileIgnoreOverrides = (userConfig) => {
|
|
1144
|
+
const overrides = userConfig?.ignore?.overrides;
|
|
1145
|
+
if (overrides === void 0) return [];
|
|
1146
|
+
if (!Array.isArray(overrides)) {
|
|
1147
|
+
warnConfigField(`ignore.overrides must be an array of { files, rules } entries; ignoring.`);
|
|
1148
|
+
return [];
|
|
1149
|
+
}
|
|
1150
|
+
return overrides.flatMap((entry, index) => {
|
|
1151
|
+
const validated = validateOverrideEntry(entry, index);
|
|
1152
|
+
if (!validated) return [];
|
|
1153
|
+
const filePatterns = collectStringList(validated.files).map(compileGlobPattern);
|
|
1154
|
+
if (filePatterns.length === 0) return [];
|
|
1155
|
+
return [{
|
|
1156
|
+
filePatterns,
|
|
1157
|
+
ruleIds: new Set(collectStringList(validated.rules))
|
|
1158
|
+
}];
|
|
1159
|
+
});
|
|
1160
|
+
};
|
|
1161
|
+
const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) => {
|
|
1162
|
+
if (overrides.length === 0) return false;
|
|
1163
|
+
const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory);
|
|
1164
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1165
|
+
return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
|
|
1166
|
+
};
|
|
1167
|
+
//#endregion
|
|
1168
|
+
//#region src/utils/find-jsx-opener-span.ts
|
|
1169
|
+
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
1170
|
+
const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
|
|
1171
|
+
const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
|
|
1172
|
+
let stringDelimiter = null;
|
|
1173
|
+
for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
|
|
1174
|
+
const character = line[charIndex];
|
|
1175
|
+
if (stringDelimiter !== null) {
|
|
1176
|
+
if (character === "\\") {
|
|
1177
|
+
charIndex++;
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
if (character === stringDelimiter) stringDelimiter = null;
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
1184
|
+
stringDelimiter = character;
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
if (character === "/" && line[charIndex + 1] === "/") return true;
|
|
1188
|
+
}
|
|
1189
|
+
return false;
|
|
1190
|
+
};
|
|
1191
|
+
const findOpenerTagOnLine = (line) => {
|
|
1192
|
+
for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
|
|
1193
|
+
if (match.index === void 0) continue;
|
|
1194
|
+
if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
|
|
1195
|
+
}
|
|
1196
|
+
return null;
|
|
1197
|
+
};
|
|
1198
|
+
const findJsxOpenerSpan = (lines, openerLineIndex) => {
|
|
1199
|
+
const openerLine = lines[openerLineIndex];
|
|
1200
|
+
if (openerLine === void 0) return null;
|
|
1201
|
+
const opener = findOpenerTagOnLine(openerLine);
|
|
1202
|
+
if (!opener) return null;
|
|
1203
|
+
const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
|
|
1204
|
+
let braceDepth = 0;
|
|
1205
|
+
let innerAngleDepth = 0;
|
|
1206
|
+
let stringDelimiter = null;
|
|
1207
|
+
for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
|
|
1208
|
+
const currentLine = lines[lineIndex];
|
|
1209
|
+
const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
|
|
1210
|
+
for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
|
|
1211
|
+
const character = currentLine[charIndex];
|
|
1212
|
+
if (stringDelimiter !== null) {
|
|
1213
|
+
if (character === "\\") {
|
|
1214
|
+
charIndex++;
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
if (character === stringDelimiter) stringDelimiter = null;
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
1221
|
+
stringDelimiter = character;
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
if (character === "{") {
|
|
1225
|
+
braceDepth++;
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
if (character === "}") {
|
|
1229
|
+
braceDepth--;
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
if (braceDepth !== 0) continue;
|
|
1233
|
+
if (character === "<") {
|
|
1234
|
+
const followCharacter = currentLine[charIndex + 1];
|
|
1235
|
+
if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
if (character !== ">") continue;
|
|
1239
|
+
const previousCharacter = currentLine[charIndex - 1];
|
|
1240
|
+
const nextCharacter = currentLine[charIndex + 1];
|
|
1241
|
+
if (previousCharacter === "=" || nextCharacter === "=") continue;
|
|
1242
|
+
if (innerAngleDepth > 0) {
|
|
1243
|
+
innerAngleDepth--;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
return lineIndex;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
};
|
|
1251
|
+
//#endregion
|
|
1252
|
+
//#region src/utils/find-enclosing-jsx-opener.ts
|
|
1253
|
+
const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
|
|
1254
|
+
for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
|
|
1255
|
+
const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
|
|
1256
|
+
if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
|
|
1257
|
+
}
|
|
1258
|
+
return null;
|
|
1259
|
+
};
|
|
1260
|
+
//#endregion
|
|
1261
|
+
//#region src/utils/find-stacked-disable-comments.ts
|
|
1262
|
+
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1263
|
+
const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
1264
|
+
const collected = [];
|
|
1265
|
+
let isStillInChain = true;
|
|
1266
|
+
for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
|
|
1267
|
+
const candidateLine = lines[candidateIndex];
|
|
1268
|
+
if (candidateLine === void 0) break;
|
|
1269
|
+
const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
|
|
1270
|
+
if (match) {
|
|
1271
|
+
collected.push({
|
|
1272
|
+
commentLineIndex: candidateIndex,
|
|
1273
|
+
ruleList: match[1],
|
|
1274
|
+
isInChain: isStillInChain
|
|
1275
|
+
});
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
isStillInChain = false;
|
|
1279
|
+
}
|
|
1280
|
+
return collected;
|
|
1281
|
+
};
|
|
1282
|
+
//#endregion
|
|
1283
|
+
//#region src/utils/is-rule-listed-in-comment.ts
|
|
1284
|
+
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
1285
|
+
if (!ruleList?.trim()) return true;
|
|
1286
|
+
return ruleList.split(/[,\s]+/).some((token) => token.trim() === ruleId);
|
|
1287
|
+
};
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/utils/evaluate-suppression.ts
|
|
1290
|
+
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1291
|
+
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
1292
|
+
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1293
|
+
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
1294
|
+
const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1295
|
+
const buildAdjacentMismatchHint = (comment, ruleId) => {
|
|
1296
|
+
const ruleListText = comment.ruleList?.trim() ?? "";
|
|
1297
|
+
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}`;
|
|
1298
|
+
};
|
|
1299
|
+
const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
|
|
1300
|
+
const commentLineNumber = comment.commentLineIndex + 1;
|
|
1301
|
+
const diagnosticLineNumber = diagnosticLineIndex + 1;
|
|
1302
|
+
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.`;
|
|
1303
|
+
};
|
|
1304
|
+
const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
|
|
1305
|
+
for (const comments of commentsByAnchor) {
|
|
1306
|
+
const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
|
|
1307
|
+
if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
|
|
1308
|
+
const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
|
|
1309
|
+
if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
|
|
1310
|
+
}
|
|
1311
|
+
return null;
|
|
1312
|
+
};
|
|
1313
|
+
const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
|
|
1314
|
+
const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
|
|
1315
|
+
if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
|
|
1316
|
+
isSuppressed: true,
|
|
1317
|
+
nearMissHint: null
|
|
1318
|
+
};
|
|
1319
|
+
const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
|
|
1320
|
+
if (hasChainSuppressor(directComments, ruleId)) return {
|
|
1321
|
+
isSuppressed: true,
|
|
1322
|
+
nearMissHint: null
|
|
1323
|
+
};
|
|
1324
|
+
const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
|
|
1325
|
+
const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
|
|
1326
|
+
if (hasChainSuppressor(openerComments, ruleId)) return {
|
|
1327
|
+
isSuppressed: true,
|
|
1328
|
+
nearMissHint: null
|
|
1329
|
+
};
|
|
1330
|
+
return {
|
|
1331
|
+
isSuppressed: false,
|
|
1332
|
+
nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
|
|
1333
|
+
};
|
|
1334
|
+
};
|
|
1335
|
+
//#endregion
|
|
1336
|
+
//#region src/utils/is-ignored-file.ts
|
|
1337
|
+
const compileIgnoredFilePatterns = (userConfig) => {
|
|
1338
|
+
const files = userConfig?.ignore?.files;
|
|
1339
|
+
if (!Array.isArray(files)) return [];
|
|
1340
|
+
return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
|
|
1341
|
+
};
|
|
1342
|
+
const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
|
|
1343
|
+
if (patterns.length === 0) return false;
|
|
1344
|
+
const relativePath = toRelativePath(filePath, rootDirectory);
|
|
1345
|
+
return patterns.some((pattern) => pattern.test(relativePath));
|
|
1346
|
+
};
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region src/utils/filter-diagnostics.ts
|
|
1349
|
+
const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
|
|
1350
|
+
const resolveCandidateReadPath = (rootDirectory, filePath) => {
|
|
1351
|
+
const normalizedFile = filePath.replace(/\\/g, "/");
|
|
1352
|
+
if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
|
|
1353
|
+
return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
|
|
1354
|
+
};
|
|
1355
|
+
const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
|
|
1356
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1357
|
+
return (filePath) => {
|
|
1358
|
+
const cached = cache.get(filePath);
|
|
1359
|
+
if (cached !== void 0) return cached;
|
|
1360
|
+
const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
|
|
1361
|
+
cache.set(filePath, lines);
|
|
1362
|
+
return lines;
|
|
1363
|
+
};
|
|
1364
|
+
};
|
|
1365
|
+
const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
|
|
1366
|
+
for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
|
|
1367
|
+
const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
|
|
1368
|
+
if (!match) continue;
|
|
1369
|
+
const fullTagName = match[1];
|
|
1370
|
+
const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
|
|
1371
|
+
return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
|
|
1067
1372
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1373
|
+
return false;
|
|
1374
|
+
};
|
|
1375
|
+
const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
|
|
1376
|
+
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
|
|
1377
|
+
const ignoredFilePatterns = compileIgnoredFilePatterns(config);
|
|
1378
|
+
const compiledOverrides = compileIgnoreOverrides(config);
|
|
1379
|
+
const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
|
|
1380
|
+
const hasTextComponents = textComponentNames.size > 0;
|
|
1381
|
+
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
1382
|
+
return diagnostics.filter((diagnostic) => {
|
|
1383
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1384
|
+
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
1385
|
+
if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
|
|
1386
|
+
if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
|
|
1387
|
+
if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
|
|
1388
|
+
const lines = getFileLines(diagnostic.filePath);
|
|
1389
|
+
if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
|
|
1078
1390
|
}
|
|
1079
|
-
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1391
|
+
return true;
|
|
1392
|
+
});
|
|
1393
|
+
};
|
|
1394
|
+
const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
|
|
1395
|
+
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
1396
|
+
return diagnostics.flatMap((diagnostic) => {
|
|
1397
|
+
if (diagnostic.line <= 0) return [diagnostic];
|
|
1398
|
+
const lines = getFileLines(diagnostic.filePath);
|
|
1399
|
+
if (!lines) return [diagnostic];
|
|
1400
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1401
|
+
const evaluation = evaluateSuppression(lines, diagnostic.line - 1, ruleIdentifier);
|
|
1402
|
+
if (evaluation.isSuppressed) return [];
|
|
1403
|
+
return evaluation.nearMissHint ? [{
|
|
1404
|
+
...diagnostic,
|
|
1405
|
+
suppressionHint: evaluation.nearMissHint
|
|
1406
|
+
}] : [diagnostic];
|
|
1407
|
+
});
|
|
1408
|
+
};
|
|
1409
|
+
//#endregion
|
|
1410
|
+
//#region src/utils/merge-and-filter-diagnostics.ts
|
|
1411
|
+
const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync, options = {}) => {
|
|
1412
|
+
const filtered = userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics;
|
|
1413
|
+
if (options.respectInlineDisables === false) return filtered;
|
|
1414
|
+
return filterInlineSuppressions(filtered, directory, readFileLinesSync);
|
|
1415
|
+
};
|
|
1416
|
+
//#endregion
|
|
1417
|
+
//#region src/utils/parse-react-major.ts
|
|
1418
|
+
const parseReactMajor = (reactVersion) => {
|
|
1419
|
+
if (typeof reactVersion !== "string") return null;
|
|
1420
|
+
const trimmed = reactVersion.trim();
|
|
1421
|
+
if (trimmed.length === 0) return null;
|
|
1422
|
+
const match = trimmed.match(/(\d+)/);
|
|
1423
|
+
if (!match) return null;
|
|
1424
|
+
const major = Number.parseInt(match[1], 10);
|
|
1425
|
+
if (!Number.isFinite(major) || major <= 0) return null;
|
|
1426
|
+
return major;
|
|
1083
1427
|
};
|
|
1084
1428
|
//#endregion
|
|
1085
1429
|
//#region src/utils/read-file-lines-node.ts
|
|
@@ -1138,116 +1482,6 @@ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
|
1138
1482
|
});
|
|
1139
1483
|
};
|
|
1140
1484
|
//#endregion
|
|
1141
|
-
//#region src/core/calculate-score-locally.ts
|
|
1142
|
-
const getScoreLabel = (score) => {
|
|
1143
|
-
if (score >= 75) return "Great";
|
|
1144
|
-
if (score >= 50) return "Needs work";
|
|
1145
|
-
return "Critical";
|
|
1146
|
-
};
|
|
1147
|
-
const countUniqueRules = (diagnostics) => {
|
|
1148
|
-
const errorRules = /* @__PURE__ */ new Set();
|
|
1149
|
-
const warningRules = /* @__PURE__ */ new Set();
|
|
1150
|
-
for (const diagnostic of diagnostics) {
|
|
1151
|
-
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1152
|
-
if (diagnostic.severity === "error") errorRules.add(ruleKey);
|
|
1153
|
-
else warningRules.add(ruleKey);
|
|
1154
|
-
}
|
|
1155
|
-
return {
|
|
1156
|
-
errorRuleCount: errorRules.size,
|
|
1157
|
-
warningRuleCount: warningRules.size
|
|
1158
|
-
};
|
|
1159
|
-
};
|
|
1160
|
-
const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
|
|
1161
|
-
const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
|
|
1162
|
-
return Math.max(0, Math.round(100 - penalty));
|
|
1163
|
-
};
|
|
1164
|
-
const calculateScoreLocally = (diagnostics) => {
|
|
1165
|
-
const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
|
|
1166
|
-
const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
|
|
1167
|
-
return {
|
|
1168
|
-
score,
|
|
1169
|
-
label: getScoreLabel(score)
|
|
1170
|
-
};
|
|
1171
|
-
};
|
|
1172
|
-
//#endregion
|
|
1173
|
-
//#region src/core/try-score-from-api.ts
|
|
1174
|
-
const parseScoreResult = (value) => {
|
|
1175
|
-
if (typeof value !== "object" || value === null) return null;
|
|
1176
|
-
if (!("score" in value) || !("label" in value)) return null;
|
|
1177
|
-
const scoreValue = Reflect.get(value, "score");
|
|
1178
|
-
const labelValue = Reflect.get(value, "label");
|
|
1179
|
-
if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
|
|
1180
|
-
return {
|
|
1181
|
-
score: scoreValue,
|
|
1182
|
-
label: labelValue
|
|
1183
|
-
};
|
|
1184
|
-
};
|
|
1185
|
-
const stripFilePaths = (diagnostics) => diagnostics.map(({ filePath: _filePath, ...rest }) => rest);
|
|
1186
|
-
const isAbortError = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
|
|
1187
|
-
const describeFailure = (error) => {
|
|
1188
|
-
if (isAbortError(error)) return `timed out after ${FETCH_TIMEOUT_MS / 1e3}s`;
|
|
1189
|
-
if (error instanceof Error && error.message) return error.message;
|
|
1190
|
-
return String(error);
|
|
1191
|
-
};
|
|
1192
|
-
const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
|
|
1193
|
-
if (typeof fetchImplementation !== "function") return null;
|
|
1194
|
-
const controller = new AbortController();
|
|
1195
|
-
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
1196
|
-
try {
|
|
1197
|
-
const response = await fetchImplementation(SCORE_API_URL, {
|
|
1198
|
-
method: "POST",
|
|
1199
|
-
headers: { "Content-Type": "application/json" },
|
|
1200
|
-
body: JSON.stringify({ diagnostics: stripFilePaths(diagnostics) }),
|
|
1201
|
-
signal: controller.signal
|
|
1202
|
-
});
|
|
1203
|
-
if (!response.ok) {
|
|
1204
|
-
console.warn(`[react-doctor] Score API returned ${response.status} ${response.statusText} — using local scoring`);
|
|
1205
|
-
return null;
|
|
1206
|
-
}
|
|
1207
|
-
return parseScoreResult(await response.json());
|
|
1208
|
-
} catch (error) {
|
|
1209
|
-
console.warn(`[react-doctor] Score API unreachable (${describeFailure(error)}) — using local scoring`);
|
|
1210
|
-
return null;
|
|
1211
|
-
} finally {
|
|
1212
|
-
clearTimeout(timeoutId);
|
|
1213
|
-
}
|
|
1214
|
-
};
|
|
1215
|
-
//#endregion
|
|
1216
|
-
//#region src/utils/calculate-score-browser.ts
|
|
1217
|
-
const getGlobalFetch = () => typeof fetch === "function" ? fetch : void 0;
|
|
1218
|
-
const calculateScore$1 = async (diagnostics, fetchImplementation = getGlobalFetch()) => await tryScoreFromApi(diagnostics, fetchImplementation) ?? calculateScoreLocally(diagnostics);
|
|
1219
|
-
//#endregion
|
|
1220
|
-
//#region src/utils/proxy-fetch.ts
|
|
1221
|
-
const getGlobalProcess = () => {
|
|
1222
|
-
const candidate = globalThis.process;
|
|
1223
|
-
return candidate?.versions?.node ? candidate : void 0;
|
|
1224
|
-
};
|
|
1225
|
-
const getProxyUrl = () => {
|
|
1226
|
-
const proc = getGlobalProcess();
|
|
1227
|
-
if (!proc?.env) return void 0;
|
|
1228
|
-
return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
|
|
1229
|
-
};
|
|
1230
|
-
const createProxyDispatcher = async (proxyUrl) => {
|
|
1231
|
-
try {
|
|
1232
|
-
const { ProxyAgent } = await import("undici");
|
|
1233
|
-
return new ProxyAgent(proxyUrl);
|
|
1234
|
-
} catch {
|
|
1235
|
-
return null;
|
|
1236
|
-
}
|
|
1237
|
-
};
|
|
1238
|
-
const proxyFetch = async (url, init) => {
|
|
1239
|
-
const proxyUrl = getProxyUrl();
|
|
1240
|
-
const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
|
|
1241
|
-
const fetchInit = {
|
|
1242
|
-
...init,
|
|
1243
|
-
...dispatcher ? { dispatcher } : {}
|
|
1244
|
-
};
|
|
1245
|
-
return fetch(url, fetchInit);
|
|
1246
|
-
};
|
|
1247
|
-
//#endregion
|
|
1248
|
-
//#region src/utils/calculate-score-node.ts
|
|
1249
|
-
const calculateScore = (diagnostics) => calculateScore$1(diagnostics, proxyFetch);
|
|
1250
|
-
//#endregion
|
|
1251
1485
|
//#region src/utils/collect-unused-file-paths.ts
|
|
1252
1486
|
const collectUnusedFilePaths = (filesIssues) => {
|
|
1253
1487
|
if (filesIssues instanceof Set) return [...filesIssues];
|
|
@@ -1277,36 +1511,57 @@ const extractFailedPluginName = (error) => {
|
|
|
1277
1511
|
//#region src/utils/has-knip-config.ts
|
|
1278
1512
|
const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
|
|
1279
1513
|
//#endregion
|
|
1514
|
+
//#region src/utils/sanitize-knip-config-patterns.ts
|
|
1515
|
+
const isMeaningfulPattern = (value) => typeof value !== "string" || value.trim().length > 0;
|
|
1516
|
+
const sanitizeStringArray = (values) => values.filter((entry) => typeof entry === "string" ? entry.trim().length > 0 : true);
|
|
1517
|
+
const sanitizeKnipConfigPatterns = (parsedConfig) => {
|
|
1518
|
+
for (const [key, value] of Object.entries(parsedConfig)) {
|
|
1519
|
+
if (typeof value === "string") {
|
|
1520
|
+
if (!isMeaningfulPattern(value)) delete parsedConfig[key];
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
if (Array.isArray(value)) {
|
|
1524
|
+
if (value.length === 0) continue;
|
|
1525
|
+
const sanitized = sanitizeStringArray(value);
|
|
1526
|
+
if (sanitized.length === value.length) continue;
|
|
1527
|
+
if (sanitized.length === 0) delete parsedConfig[key];
|
|
1528
|
+
else parsedConfig[key] = sanitized;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
if (isPlainObject(value)) sanitizeKnipConfigPatterns(value);
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
//#endregion
|
|
1280
1535
|
//#region src/utils/run-knip.ts
|
|
1281
|
-
const KNIP_ISSUE_TYPE_DESCRIPTORS =
|
|
1282
|
-
files
|
|
1536
|
+
const KNIP_ISSUE_TYPE_DESCRIPTORS = new Map([
|
|
1537
|
+
["files", {
|
|
1283
1538
|
category: "Dead Code",
|
|
1284
1539
|
message: "Unused file",
|
|
1285
1540
|
severity: "warning"
|
|
1286
|
-
},
|
|
1287
|
-
exports
|
|
1541
|
+
}],
|
|
1542
|
+
["exports", {
|
|
1288
1543
|
category: "Dead Code",
|
|
1289
1544
|
message: "Unused export",
|
|
1290
1545
|
severity: "warning"
|
|
1291
|
-
},
|
|
1292
|
-
types
|
|
1546
|
+
}],
|
|
1547
|
+
["types", {
|
|
1293
1548
|
category: "Dead Code",
|
|
1294
1549
|
message: "Unused type",
|
|
1295
1550
|
severity: "warning"
|
|
1296
|
-
},
|
|
1297
|
-
duplicates
|
|
1551
|
+
}],
|
|
1552
|
+
["duplicates", {
|
|
1298
1553
|
category: "Dead Code",
|
|
1299
1554
|
message: "Duplicate export",
|
|
1300
1555
|
severity: "warning"
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1556
|
+
}]
|
|
1557
|
+
]);
|
|
1303
1558
|
const FALLBACK_KNIP_DESCRIPTOR = {
|
|
1304
1559
|
category: "Dead Code",
|
|
1305
1560
|
message: "Issue",
|
|
1306
1561
|
severity: "warning"
|
|
1307
1562
|
};
|
|
1308
1563
|
const collectIssueRecords = (records, issueType, rootDirectory) => {
|
|
1309
|
-
const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS
|
|
1564
|
+
const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get(issueType) ?? FALLBACK_KNIP_DESCRIPTOR;
|
|
1310
1565
|
const diagnostics = [];
|
|
1311
1566
|
for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
|
|
1312
1567
|
filePath: path.relative(rootDirectory, issue.filePath),
|
|
@@ -1344,7 +1599,7 @@ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
|
|
|
1344
1599
|
const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
|
|
1345
1600
|
const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
|
|
1346
1601
|
const failedPlugin = extractFailedPluginName(error);
|
|
1347
|
-
if (!failedPlugin || !(failedPlugin
|
|
1602
|
+
if (!failedPlugin || !Object.hasOwn(parsedConfig, failedPlugin) || disabledPlugins.has(failedPlugin)) return false;
|
|
1348
1603
|
disabledPlugins.add(failedPlugin);
|
|
1349
1604
|
parsedConfig[failedPlugin] = false;
|
|
1350
1605
|
return true;
|
|
@@ -1358,6 +1613,7 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
|
1358
1613
|
...tsConfigFile ? { tsConfigFile } : {}
|
|
1359
1614
|
}));
|
|
1360
1615
|
const parsedConfig = options.parsedConfig;
|
|
1616
|
+
sanitizeKnipConfigPatterns(parsedConfig);
|
|
1361
1617
|
const disabledPlugins = /* @__PURE__ */ new Set();
|
|
1362
1618
|
let lastKnipError;
|
|
1363
1619
|
for (let attempt = 0; attempt < 6; attempt++) try {
|
|
@@ -1389,7 +1645,7 @@ const runKnip = async (rootDirectory) => {
|
|
|
1389
1645
|
if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
|
|
1390
1646
|
const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
|
|
1391
1647
|
const diagnostics = [];
|
|
1392
|
-
const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
|
|
1648
|
+
const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get("files") ?? FALLBACK_KNIP_DESCRIPTOR;
|
|
1393
1649
|
for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
|
|
1394
1650
|
filePath: path.relative(rootDirectory, unusedFilePath),
|
|
1395
1651
|
plugin: "knip",
|
|
@@ -1432,6 +1688,103 @@ const batchIncludePaths = (baseArgs, includePaths) => {
|
|
|
1432
1688
|
return batches;
|
|
1433
1689
|
};
|
|
1434
1690
|
//#endregion
|
|
1691
|
+
//#region src/utils/can-oxlint-extend-config.ts
|
|
1692
|
+
const EXTENDS_LOCAL_PATH_PREFIXES = [
|
|
1693
|
+
"./",
|
|
1694
|
+
"../",
|
|
1695
|
+
"/"
|
|
1696
|
+
];
|
|
1697
|
+
const isLocalPathExtend = (entry) => {
|
|
1698
|
+
for (const prefix of EXTENDS_LOCAL_PATH_PREFIXES) if (entry.startsWith(prefix)) return true;
|
|
1699
|
+
return false;
|
|
1700
|
+
};
|
|
1701
|
+
const stripJsoncComments = (raw) => {
|
|
1702
|
+
let result = "";
|
|
1703
|
+
let cursor = 0;
|
|
1704
|
+
let inString = false;
|
|
1705
|
+
let stringQuote = "";
|
|
1706
|
+
while (cursor < raw.length) {
|
|
1707
|
+
const character = raw[cursor];
|
|
1708
|
+
const nextCharacter = raw[cursor + 1];
|
|
1709
|
+
if (inString) {
|
|
1710
|
+
result += character;
|
|
1711
|
+
if (character === "\\" && cursor + 1 < raw.length) {
|
|
1712
|
+
result += nextCharacter;
|
|
1713
|
+
cursor += 2;
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
if (character === stringQuote) inString = false;
|
|
1717
|
+
cursor += 1;
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
if (character === "\"" || character === "'") {
|
|
1721
|
+
inString = true;
|
|
1722
|
+
stringQuote = character;
|
|
1723
|
+
result += character;
|
|
1724
|
+
cursor += 1;
|
|
1725
|
+
continue;
|
|
1726
|
+
}
|
|
1727
|
+
if (character === "/" && nextCharacter === "/") {
|
|
1728
|
+
const lineEndIndex = raw.indexOf("\n", cursor);
|
|
1729
|
+
cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
if (character === "/" && nextCharacter === "*") {
|
|
1733
|
+
const blockEndIndex = raw.indexOf("*/", cursor + 2);
|
|
1734
|
+
cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
result += character;
|
|
1738
|
+
cursor += 1;
|
|
1739
|
+
}
|
|
1740
|
+
return result;
|
|
1741
|
+
};
|
|
1742
|
+
const parseJsonOrJsonc = (raw) => {
|
|
1743
|
+
try {
|
|
1744
|
+
return JSON.parse(raw);
|
|
1745
|
+
} catch {
|
|
1746
|
+
return JSON.parse(stripJsoncComments(raw));
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
const canOxlintExtendConfig = (configPath) => {
|
|
1750
|
+
if (!configPath.endsWith(".eslintrc.json")) return true;
|
|
1751
|
+
let parsed;
|
|
1752
|
+
try {
|
|
1753
|
+
parsed = parseJsonOrJsonc(fs.readFileSync(configPath, "utf-8"));
|
|
1754
|
+
} catch {
|
|
1755
|
+
return true;
|
|
1756
|
+
}
|
|
1757
|
+
if (!isPlainObject(parsed)) return true;
|
|
1758
|
+
const extendsValue = parsed.extends;
|
|
1759
|
+
if (extendsValue === void 0 || extendsValue === null) return true;
|
|
1760
|
+
const extendsEntries = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
|
|
1761
|
+
if (extendsEntries.length === 0) return true;
|
|
1762
|
+
return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
|
|
1763
|
+
};
|
|
1764
|
+
//#endregion
|
|
1765
|
+
//#region src/utils/detect-user-lint-config.ts
|
|
1766
|
+
const findFirstLintConfigInDirectory = (directory) => {
|
|
1767
|
+
for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
|
|
1768
|
+
const candidatePath = path.join(directory, filename);
|
|
1769
|
+
if (isFile(candidatePath)) return candidatePath;
|
|
1770
|
+
}
|
|
1771
|
+
return null;
|
|
1772
|
+
};
|
|
1773
|
+
const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1774
|
+
const detectUserLintConfigPaths = (rootDirectory) => {
|
|
1775
|
+
const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
|
|
1776
|
+
if (directLintConfig) return [directLintConfig];
|
|
1777
|
+
if (isProjectBoundary(rootDirectory)) return [];
|
|
1778
|
+
let ancestorDirectory = path.dirname(rootDirectory);
|
|
1779
|
+
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
1780
|
+
const ancestorLintConfig = findFirstLintConfigInDirectory(ancestorDirectory);
|
|
1781
|
+
if (ancestorLintConfig) return [ancestorLintConfig];
|
|
1782
|
+
if (isProjectBoundary(ancestorDirectory)) return [];
|
|
1783
|
+
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
1784
|
+
}
|
|
1785
|
+
return [];
|
|
1786
|
+
};
|
|
1787
|
+
//#endregion
|
|
1435
1788
|
//#region src/oxlint-config.ts
|
|
1436
1789
|
const esmRequire$1 = createRequire(import.meta.url);
|
|
1437
1790
|
const NEXTJS_RULES = {
|
|
@@ -1512,16 +1865,45 @@ const REACT_COMPILER_RULES = {
|
|
|
1512
1865
|
"react-hooks-js/incompatible-library": "warn",
|
|
1513
1866
|
"react-hooks-js/todo": "warn"
|
|
1514
1867
|
};
|
|
1868
|
+
const readPluginRuleNames = (pluginSpecifier) => {
|
|
1869
|
+
try {
|
|
1870
|
+
const pluginModule = esmRequire$1(pluginSpecifier);
|
|
1871
|
+
const rules = pluginModule.rules ?? pluginModule.default?.rules;
|
|
1872
|
+
if (rules === void 0) return /* @__PURE__ */ new Set();
|
|
1873
|
+
return new Set(Object.keys(rules));
|
|
1874
|
+
} catch {
|
|
1875
|
+
return /* @__PURE__ */ new Set();
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1515
1878
|
const resolveReactHooksJsPlugin = (hasReactCompiler, customRulesOnly) => {
|
|
1516
1879
|
if (!hasReactCompiler || customRulesOnly) return null;
|
|
1880
|
+
let pluginSpecifier;
|
|
1517
1881
|
try {
|
|
1518
|
-
|
|
1519
|
-
name: "react-hooks-js",
|
|
1520
|
-
specifier: esmRequire$1.resolve("eslint-plugin-react-hooks")
|
|
1521
|
-
};
|
|
1882
|
+
pluginSpecifier = esmRequire$1.resolve("eslint-plugin-react-hooks");
|
|
1522
1883
|
} catch {
|
|
1523
1884
|
return null;
|
|
1524
1885
|
}
|
|
1886
|
+
return {
|
|
1887
|
+
entry: {
|
|
1888
|
+
name: "react-hooks-js",
|
|
1889
|
+
specifier: pluginSpecifier
|
|
1890
|
+
},
|
|
1891
|
+
availableRuleNames: readPluginRuleNames(pluginSpecifier)
|
|
1892
|
+
};
|
|
1893
|
+
};
|
|
1894
|
+
const filterRulesToAvailable = (rules, pluginNamespace, availableRuleNames) => {
|
|
1895
|
+
if (availableRuleNames.size === 0) return rules;
|
|
1896
|
+
const ruleKeyPrefix = `${pluginNamespace}/`;
|
|
1897
|
+
const filtered = {};
|
|
1898
|
+
for (const [ruleKey, severity] of Object.entries(rules)) {
|
|
1899
|
+
if (!ruleKey.startsWith(ruleKeyPrefix)) {
|
|
1900
|
+
filtered[ruleKey] = severity;
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
const ruleName = ruleKey.slice(ruleKeyPrefix.length);
|
|
1904
|
+
if (availableRuleNames.has(ruleName)) filtered[ruleKey] = severity;
|
|
1905
|
+
}
|
|
1906
|
+
return filtered;
|
|
1525
1907
|
};
|
|
1526
1908
|
const TANSTACK_QUERY_RULES = {
|
|
1527
1909
|
"react-doctor/query-stable-query-client": "warn",
|
|
@@ -1564,18 +1946,27 @@ const BUILTIN_A11Y_RULES = {
|
|
|
1564
1946
|
const GLOBAL_REACT_DOCTOR_RULES = {
|
|
1565
1947
|
"react-doctor/no-derived-state-effect": "warn",
|
|
1566
1948
|
"react-doctor/no-fetch-in-effect": "warn",
|
|
1949
|
+
"react-doctor/no-mirror-prop-effect": "warn",
|
|
1950
|
+
"react-doctor/no-mutable-in-deps": "error",
|
|
1567
1951
|
"react-doctor/no-cascading-set-state": "warn",
|
|
1952
|
+
"react-doctor/no-effect-chain": "warn",
|
|
1568
1953
|
"react-doctor/no-effect-event-handler": "warn",
|
|
1569
1954
|
"react-doctor/no-effect-event-in-deps": "error",
|
|
1955
|
+
"react-doctor/no-event-trigger-state": "warn",
|
|
1570
1956
|
"react-doctor/no-prop-callback-in-effect": "warn",
|
|
1571
1957
|
"react-doctor/no-derived-useState": "warn",
|
|
1958
|
+
"react-doctor/no-direct-state-mutation": "warn",
|
|
1959
|
+
"react-doctor/no-set-state-in-render": "warn",
|
|
1960
|
+
"react-doctor/prefer-use-effect-event": "warn",
|
|
1572
1961
|
"react-doctor/prefer-useReducer": "warn",
|
|
1962
|
+
"react-doctor/prefer-use-sync-external-store": "warn",
|
|
1573
1963
|
"react-doctor/rerender-lazy-state-init": "warn",
|
|
1574
1964
|
"react-doctor/rerender-functional-setstate": "warn",
|
|
1575
1965
|
"react-doctor/rerender-dependencies": "error",
|
|
1576
1966
|
"react-doctor/rerender-state-only-in-handlers": "warn",
|
|
1577
1967
|
"react-doctor/rerender-defer-reads-hook": "warn",
|
|
1578
1968
|
"react-doctor/advanced-event-handler-refs": "warn",
|
|
1969
|
+
"react-doctor/effect-needs-cleanup": "error",
|
|
1579
1970
|
"react-doctor/no-giant-component": "warn",
|
|
1580
1971
|
"react-doctor/no-render-in-render": "warn",
|
|
1581
1972
|
"react-doctor/no-many-boolean-props": "warn",
|
|
@@ -1583,6 +1974,10 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1583
1974
|
"react-doctor/no-render-prop-children": "warn",
|
|
1584
1975
|
"react-doctor/no-nested-component-definition": "error",
|
|
1585
1976
|
"react-doctor/react-compiler-destructure-method": "warn",
|
|
1977
|
+
"react-doctor/no-legacy-class-lifecycles": "error",
|
|
1978
|
+
"react-doctor/no-legacy-context-api": "error",
|
|
1979
|
+
"react-doctor/no-default-props": "warn",
|
|
1980
|
+
"react-doctor/no-react-dom-deprecated-apis": "warn",
|
|
1586
1981
|
"react-doctor/no-usememo-simple-expression": "warn",
|
|
1587
1982
|
"react-doctor/no-layout-property-animation": "error",
|
|
1588
1983
|
"react-doctor/rerender-memo-with-default-value": "warn",
|
|
@@ -1631,6 +2026,7 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1631
2026
|
"react-doctor/rendering-conditional-render": "warn",
|
|
1632
2027
|
"react-doctor/rendering-svg-precision": "warn",
|
|
1633
2028
|
"react-doctor/no-prevent-default": "warn",
|
|
2029
|
+
"react-doctor/no-uncontrolled-input": "warn",
|
|
1634
2030
|
"react-doctor/no-document-start-view-transition": "warn",
|
|
1635
2031
|
"react-doctor/no-flush-sync": "warn",
|
|
1636
2032
|
"react-doctor/server-auth-actions": "error",
|
|
@@ -1658,6 +2054,14 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1658
2054
|
"react-doctor/no-disabled-zoom": "error",
|
|
1659
2055
|
"react-doctor/no-outline-none": "warn",
|
|
1660
2056
|
"react-doctor/no-long-transition-duration": "warn",
|
|
2057
|
+
"react-doctor/design-no-bold-heading": "warn",
|
|
2058
|
+
"react-doctor/design-no-redundant-padding-axes": "warn",
|
|
2059
|
+
"react-doctor/design-no-redundant-size-axes": "warn",
|
|
2060
|
+
"react-doctor/design-no-space-on-flex-children": "warn",
|
|
2061
|
+
"react-doctor/design-no-em-dash-in-jsx-text": "warn",
|
|
2062
|
+
"react-doctor/design-no-three-period-ellipsis": "warn",
|
|
2063
|
+
"react-doctor/design-no-default-tailwind-palette": "warn",
|
|
2064
|
+
"react-doctor/design-no-vague-button-label": "warn",
|
|
1661
2065
|
"react-doctor/async-parallel": "warn"
|
|
1662
2066
|
};
|
|
1663
2067
|
const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
@@ -1667,9 +2071,38 @@ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
|
1667
2071
|
...Object.keys(TANSTACK_START_RULES),
|
|
1668
2072
|
...Object.keys(TANSTACK_QUERY_RULES)
|
|
1669
2073
|
]);
|
|
1670
|
-
const
|
|
2074
|
+
const VERSION_GATED_RULE_IDS = new Map([
|
|
2075
|
+
["react-doctor/no-react19-deprecated-apis", {
|
|
2076
|
+
minMajor: 19,
|
|
2077
|
+
mode: "deprecation-warning"
|
|
2078
|
+
}],
|
|
2079
|
+
["react-doctor/no-default-props", {
|
|
2080
|
+
minMajor: 19,
|
|
2081
|
+
mode: "deprecation-warning"
|
|
2082
|
+
}],
|
|
2083
|
+
["react-doctor/no-react-dom-deprecated-apis", {
|
|
2084
|
+
minMajor: 18,
|
|
2085
|
+
mode: "deprecation-warning"
|
|
2086
|
+
}],
|
|
2087
|
+
["react-doctor/prefer-use-effect-event", {
|
|
2088
|
+
minMajor: 19,
|
|
2089
|
+
mode: "prefer-newer-api"
|
|
2090
|
+
}]
|
|
2091
|
+
]);
|
|
2092
|
+
const filterRulesByReactMajor = (rules, reactMajorVersion) => {
|
|
2093
|
+
return Object.fromEntries(Object.entries(rules).filter(([ruleKey]) => {
|
|
2094
|
+
const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
|
|
2095
|
+
if (gate === void 0) return true;
|
|
2096
|
+
if (gate.mode === "deprecation-warning") return true;
|
|
2097
|
+
if (reactMajorVersion === null) return true;
|
|
2098
|
+
return reactMajorVersion >= gate.minMajor;
|
|
2099
|
+
}));
|
|
2100
|
+
};
|
|
2101
|
+
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
|
|
1671
2102
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
2103
|
+
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
1672
2104
|
return {
|
|
2105
|
+
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
1673
2106
|
categories: {
|
|
1674
2107
|
correctness: "off",
|
|
1675
2108
|
suspicious: "off",
|
|
@@ -1680,12 +2113,12 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
|
|
|
1680
2113
|
nursery: "off"
|
|
1681
2114
|
},
|
|
1682
2115
|
plugins: customRulesOnly ? [] : ["react", "jsx-a11y"],
|
|
1683
|
-
jsPlugins: reactHooksJsPlugin ? [reactHooksJsPlugin, pluginPath] : [pluginPath],
|
|
2116
|
+
jsPlugins: reactHooksJsPlugin ? [reactHooksJsPlugin.entry, pluginPath] : [pluginPath],
|
|
1684
2117
|
rules: {
|
|
1685
2118
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
1686
2119
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
1687
|
-
...
|
|
1688
|
-
...GLOBAL_REACT_DOCTOR_RULES,
|
|
2120
|
+
...reactCompilerRules,
|
|
2121
|
+
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
1689
2122
|
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
1690
2123
|
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
1691
2124
|
...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
|
|
@@ -1797,23 +2230,43 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
1797
2230
|
"react-hooks-js": "React Compiler",
|
|
1798
2231
|
"react-doctor": "Other",
|
|
1799
2232
|
"jsx-a11y": "Accessibility",
|
|
1800
|
-
knip: "Dead Code"
|
|
2233
|
+
knip: "Dead Code",
|
|
2234
|
+
eslint: "Correctness",
|
|
2235
|
+
oxc: "Correctness",
|
|
2236
|
+
typescript: "Correctness",
|
|
2237
|
+
unicorn: "Correctness",
|
|
2238
|
+
import: "Bundle Size",
|
|
2239
|
+
promise: "Correctness",
|
|
2240
|
+
n: "Correctness",
|
|
2241
|
+
node: "Correctness",
|
|
2242
|
+
vitest: "Correctness",
|
|
2243
|
+
jest: "Correctness",
|
|
2244
|
+
nextjs: "Next.js"
|
|
1801
2245
|
};
|
|
1802
2246
|
const RULE_CATEGORY_MAP = {
|
|
1803
2247
|
"react-doctor/no-derived-state-effect": "State & Effects",
|
|
1804
2248
|
"react-doctor/no-fetch-in-effect": "State & Effects",
|
|
2249
|
+
"react-doctor/no-mirror-prop-effect": "State & Effects",
|
|
2250
|
+
"react-doctor/no-mutable-in-deps": "State & Effects",
|
|
1805
2251
|
"react-doctor/no-cascading-set-state": "State & Effects",
|
|
2252
|
+
"react-doctor/no-effect-chain": "State & Effects",
|
|
1806
2253
|
"react-doctor/no-effect-event-handler": "State & Effects",
|
|
1807
2254
|
"react-doctor/no-effect-event-in-deps": "State & Effects",
|
|
2255
|
+
"react-doctor/no-event-trigger-state": "State & Effects",
|
|
1808
2256
|
"react-doctor/no-prop-callback-in-effect": "State & Effects",
|
|
1809
2257
|
"react-doctor/no-derived-useState": "State & Effects",
|
|
2258
|
+
"react-doctor/no-direct-state-mutation": "State & Effects",
|
|
2259
|
+
"react-doctor/no-set-state-in-render": "State & Effects",
|
|
2260
|
+
"react-doctor/prefer-use-effect-event": "State & Effects",
|
|
1810
2261
|
"react-doctor/prefer-useReducer": "State & Effects",
|
|
2262
|
+
"react-doctor/prefer-use-sync-external-store": "State & Effects",
|
|
1811
2263
|
"react-doctor/rerender-lazy-state-init": "Performance",
|
|
1812
2264
|
"react-doctor/rerender-functional-setstate": "Performance",
|
|
1813
2265
|
"react-doctor/rerender-dependencies": "State & Effects",
|
|
1814
2266
|
"react-doctor/rerender-state-only-in-handlers": "Performance",
|
|
1815
2267
|
"react-doctor/rerender-defer-reads-hook": "Performance",
|
|
1816
2268
|
"react-doctor/advanced-event-handler-refs": "Performance",
|
|
2269
|
+
"react-doctor/effect-needs-cleanup": "State & Effects",
|
|
1817
2270
|
"react-doctor/no-generic-handler-names": "Architecture",
|
|
1818
2271
|
"react-doctor/no-giant-component": "Architecture",
|
|
1819
2272
|
"react-doctor/no-many-boolean-props": "Architecture",
|
|
@@ -1822,6 +2275,10 @@ const RULE_CATEGORY_MAP = {
|
|
|
1822
2275
|
"react-doctor/no-render-in-render": "Architecture",
|
|
1823
2276
|
"react-doctor/no-nested-component-definition": "Correctness",
|
|
1824
2277
|
"react-doctor/react-compiler-destructure-method": "Architecture",
|
|
2278
|
+
"react-doctor/no-legacy-class-lifecycles": "Correctness",
|
|
2279
|
+
"react-doctor/no-legacy-context-api": "Correctness",
|
|
2280
|
+
"react-doctor/no-default-props": "Architecture",
|
|
2281
|
+
"react-doctor/no-react-dom-deprecated-apis": "Architecture",
|
|
1825
2282
|
"react-doctor/no-usememo-simple-expression": "Performance",
|
|
1826
2283
|
"react-doctor/no-layout-property-animation": "Performance",
|
|
1827
2284
|
"react-doctor/rerender-memo-with-default-value": "Performance",
|
|
@@ -1855,6 +2312,7 @@ const RULE_CATEGORY_MAP = {
|
|
|
1855
2312
|
"react-doctor/rendering-conditional-render": "Correctness",
|
|
1856
2313
|
"react-doctor/rendering-svg-precision": "Performance",
|
|
1857
2314
|
"react-doctor/no-prevent-default": "Correctness",
|
|
2315
|
+
"react-doctor/no-uncontrolled-input": "Correctness",
|
|
1858
2316
|
"react-doctor/no-document-start-view-transition": "Correctness",
|
|
1859
2317
|
"react-doctor/no-flush-sync": "Performance",
|
|
1860
2318
|
"react-doctor/nextjs-no-img-element": "Next.js",
|
|
@@ -1904,6 +2362,14 @@ const RULE_CATEGORY_MAP = {
|
|
|
1904
2362
|
"react-doctor/no-disabled-zoom": "Accessibility",
|
|
1905
2363
|
"react-doctor/no-outline-none": "Accessibility",
|
|
1906
2364
|
"react-doctor/no-long-transition-duration": "Performance",
|
|
2365
|
+
"react-doctor/design-no-bold-heading": "Architecture",
|
|
2366
|
+
"react-doctor/design-no-redundant-padding-axes": "Architecture",
|
|
2367
|
+
"react-doctor/design-no-redundant-size-axes": "Architecture",
|
|
2368
|
+
"react-doctor/design-no-space-on-flex-children": "Architecture",
|
|
2369
|
+
"react-doctor/design-no-em-dash-in-jsx-text": "Architecture",
|
|
2370
|
+
"react-doctor/design-no-three-period-ellipsis": "Architecture",
|
|
2371
|
+
"react-doctor/design-no-default-tailwind-palette": "Architecture",
|
|
2372
|
+
"react-doctor/design-no-vague-button-label": "Accessibility",
|
|
1907
2373
|
"react-doctor/js-flatmap-filter": "Performance",
|
|
1908
2374
|
"react-doctor/js-combine-iterations": "Performance",
|
|
1909
2375
|
"react-doctor/js-tosorted-immutable": "Performance",
|
|
@@ -1961,10 +2427,18 @@ const RULE_CATEGORY_MAP = {
|
|
|
1961
2427
|
const RULE_HELP_MAP = {
|
|
1962
2428
|
"no-derived-state-effect": "For derived state, compute inline: `const x = fn(dep)`. For state resets on prop change, use a key prop: `<Component key={prop} />`. See https://react.dev/learn/you-might-not-need-an-effect",
|
|
1963
2429
|
"no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
|
|
2430
|
+
"no-mirror-prop-effect": "Delete both the `useState` and the `useEffect` and read the prop directly during render. Mirroring a prop into local state forces a stale first render before the effect re-syncs",
|
|
2431
|
+
"no-mutable-in-deps": "Read mutable values (`location.pathname`, `ref.current`) inside the effect body instead of in the deps array, or subscribe with `useSyncExternalStore`. Mutations to these don't trigger re-renders, so listing them in deps doesn't make the effect react to changes",
|
|
1964
2432
|
"no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
|
|
2433
|
+
"no-effect-chain": "Compute as much as possible during render (e.g. `const isGameOver = round > 5`) and write all related state inside the event handler that originally fires the chain. Each effect link adds an extra render and makes the code rigid as requirements evolve",
|
|
1965
2434
|
"no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
|
|
2435
|
+
"no-event-trigger-state": "Delete the trigger state (`useState(null)` plus the `useEffect` that watches it) and call the side-effect (`post(...)` / `navigate(...)` / `track(...)`) directly inside the event handler that previously called the setter. State should not exist purely to schedule effect runs",
|
|
1966
2436
|
"no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
|
|
2437
|
+
"no-direct-state-mutation": "Replace the mutation with a setter call that produces a new reference: `setItems([...items, newItem])`, `setItems(items.filter(x => x !== target))`, `setItems(items.toSorted(...))`. React only re-renders on a new reference, so in-place updates are silently dropped",
|
|
2438
|
+
"no-set-state-in-render": "Move the setter call into a `useEffect`, an event handler, or replace the state with a value computed during render. Calling a setter at render time triggers another render, which calls the setter again — an infinite loop",
|
|
2439
|
+
"prefer-use-effect-event": "Wrap the callback with `useEffectEvent(callback)` (React 19+) and call the resulting binding from inside the sub-handler. The Effect Event captures the latest props/state without being a reactive dep, so the effect doesn't re-subscribe on every parent render. See https://react.dev/reference/react/useEffectEvent",
|
|
1967
2440
|
"prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
|
|
2441
|
+
"prefer-use-sync-external-store": "Replace the `useState(getSnapshot())` + `useEffect(() => store.subscribe(() => setSnapshot(getSnapshot())))` pair with `useSyncExternalStore(store.subscribe, getSnapshot)`. The hook handles tearing during concurrent renders and SSR snapshots; the manual subscribe pattern doesn't",
|
|
1968
2442
|
"rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
|
|
1969
2443
|
"rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
|
|
1970
2444
|
"rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
|
|
@@ -1973,7 +2447,11 @@ const RULE_HELP_MAP = {
|
|
|
1973
2447
|
"no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
|
|
1974
2448
|
"no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
|
|
1975
2449
|
"no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
|
|
1976
|
-
"no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads.",
|
|
2450
|
+
"no-react19-deprecated-apis": "Pass `ref` as a regular prop on function components — `forwardRef` is no longer needed in React 19+. Replace `useContext(X)` with `use(X)` for branch-aware context reads. Only enabled on projects detected as React 19+.",
|
|
2451
|
+
"no-legacy-class-lifecycles": "Move side effects in `componentWillMount` to `componentDidMount`; replace `componentWillReceiveProps` with `componentDidUpdate` (compare prevProps) or the static `getDerivedStateFromProps` for pure state derivation; replace `componentWillUpdate` with `getSnapshotBeforeUpdate` paired with `componentDidUpdate`. The `UNSAFE_` prefix only silences the warning — React 19 removes both forms.",
|
|
2452
|
+
"no-legacy-context-api": "Replace `childContextTypes` + `getChildContext` with `const MyContext = createContext(...)` + `<MyContext.Provider value={...}>`; replace `contextTypes` with `static contextType = MyContext` (single context) or `useContext()` / `use()` from a function component. The provider and every consumer must migrate together — partial migrations leave consumers reading the wrong context.",
|
|
2453
|
+
"no-default-props": "React 19 removes `Component.defaultProps` for function components. Move the defaults into the destructured props parameter: `function Foo({ size = \"md\", variant = \"primary\" })` instead of `Foo.defaultProps = { size: \"md\", variant: \"primary\" }`.",
|
|
2454
|
+
"no-react-dom-deprecated-apis": "Switch the legacy `react-dom` root API (`render` / `hydrate` / `unmountComponentAtNode`) to `createRoot` / `hydrateRoot` / `root.unmount()` from `react-dom/client`. Replace `findDOMNode` with a ref. The whole `react-dom/test-utils` entry point is removed in React 19 — use `act` from `react` and `fireEvent` / `render` from `@testing-library/react`. Only enabled on projects detected as React 18+.",
|
|
1977
2455
|
"no-render-prop-children": "Replace `renderXxx` props with compound subcomponents (e.g. `<Modal.Header>`) or `children` so the parent doesn't dictate every customization point",
|
|
1978
2456
|
"no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
|
|
1979
2457
|
"no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
|
|
@@ -1988,6 +2466,7 @@ const RULE_HELP_MAP = {
|
|
|
1988
2466
|
"rerender-defer-reads-hook": "Read the URL state inside the handler (e.g. `new URL(window.location.href).searchParams`) so the component doesn't subscribe and re-render on every URL change",
|
|
1989
2467
|
"rerender-derived-state-from-hook": "Use a threshold/media-query hook (e.g. `useMediaQuery(\"(max-width: 767px)\")`) — the component re-renders only when the threshold flips, not every pixel",
|
|
1990
2468
|
"advanced-event-handler-refs": "Store the handler in a ref and have the listener read `handlerRef.current()` — the subscription stays put while the latest handler is always called",
|
|
2469
|
+
"effect-needs-cleanup": "Return a cleanup function that releases the subscription / timer: `return () => target.removeEventListener(name, handler)` for listeners, `return () => clearInterval(id)` / `clearTimeout(id)` for timers, or `return unsubscribe` if the subscribe call already returned one",
|
|
1991
2470
|
"async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
|
|
1992
2471
|
"async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
|
|
1993
2472
|
"react-compiler-destructure-method": "Destructure the method up front: `const { push } = useRouter()` then call `push(...)` directly — clearer dependency graph and easier for React Compiler to memoize",
|
|
@@ -2034,9 +2513,18 @@ const RULE_HELP_MAP = {
|
|
|
2034
2513
|
"no-disabled-zoom": "Remove `user-scalable=no` and `maximum-scale` from the viewport meta tag. If your layout breaks at 200% zoom, fix the layout — don't punish users with disabilities",
|
|
2035
2514
|
"no-outline-none": "Use `:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px }` to show focus only for keyboard users while hiding it for mouse clicks",
|
|
2036
2515
|
"no-long-transition-duration": "Keep UI transitions under 1s — 100-150ms for instant feedback, 200-300ms for state changes, 300-500ms for layout changes. Use longer durations only for page-load hero animations",
|
|
2516
|
+
"design-no-bold-heading": "Use `font-semibold` (600) or `font-medium` (500) on headings — 700+ crushes letter counter shapes at display sizes",
|
|
2517
|
+
"design-no-redundant-padding-axes": "Collapse `px-N py-N` to `p-N` when both axes match. Keep them split only when one axis varies at a breakpoint (`py-2 md:py-3`)",
|
|
2518
|
+
"design-no-redundant-size-axes": "Collapse `w-N h-N` to `size-N` (Tailwind v3.4+) when both axes match",
|
|
2519
|
+
"design-no-space-on-flex-children": "Use `gap-*` on the flex/grid parent. `space-x-*` / `space-y-*` produce phantom gaps when a sibling is conditionally rendered, lose vertical spacing on wrapped lines, and don't mirror in RTL",
|
|
2520
|
+
"design-no-em-dash-in-jsx-text": "Replace em dashes in JSX text with commas, colons, semicolons, periods, or parentheses — em dashes read as model-output filler",
|
|
2521
|
+
"design-no-three-period-ellipsis": "Use the typographic ellipsis \"…\" (or `…`) instead of three periods — pairs with action-with-followup labels (\"Rename…\", \"Loading…\")",
|
|
2522
|
+
"design-no-default-tailwind-palette": "Replace `indigo-*` / `gray-*` / `slate-*` with project tokens, your brand color, or a less-default neutral (`zinc`, `neutral`, `stone`)",
|
|
2523
|
+
"design-no-vague-button-label": "Name the action: \"Save changes\" instead of \"Continue\", \"Send invite\" instead of \"Submit\", \"Delete account\" instead of \"OK\". The label IS the button's accessible name",
|
|
2037
2524
|
"no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
|
|
2038
2525
|
"rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
|
|
2039
2526
|
"no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
|
|
2527
|
+
"no-uncontrolled-input": "Pass an explicit initial value to `useState` (e.g. `useState(\"\")` instead of `useState()`), add `onChange` (or `readOnly` to opt out) when you supply `value`, and drop `defaultValue` on controlled inputs — React ignores it",
|
|
2040
2528
|
"nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
|
|
2041
2529
|
"nextjs-async-client-component": "Fetch data in a parent Server Component and pass it as props, or use useQuery/useSWR in the client component",
|
|
2042
2530
|
"nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
|
|
@@ -2119,6 +2607,7 @@ const RULE_HELP_MAP = {
|
|
|
2119
2607
|
};
|
|
2120
2608
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
2121
2609
|
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
2610
|
+
const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
2122
2611
|
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
2123
2612
|
if (plugin === "react-hooks-js") return {
|
|
2124
2613
|
message: REACT_COMPILER_MESSAGE,
|
|
@@ -2126,7 +2615,7 @@ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
|
2126
2615
|
};
|
|
2127
2616
|
return {
|
|
2128
2617
|
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
2129
|
-
help: help || RULE_HELP_MAP
|
|
2618
|
+
help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
|
|
2130
2619
|
};
|
|
2131
2620
|
};
|
|
2132
2621
|
const parseRuleCode = (code) => {
|
|
@@ -2154,7 +2643,7 @@ const resolvePluginPath = () => {
|
|
|
2154
2643
|
return pluginPath;
|
|
2155
2644
|
};
|
|
2156
2645
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
2157
|
-
return RULE_CATEGORY_MAP
|
|
2646
|
+
return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
|
|
2158
2647
|
};
|
|
2159
2648
|
const SANITIZED_ENV = (() => {
|
|
2160
2649
|
const sanitized = {};
|
|
@@ -2245,7 +2734,7 @@ const parseOxlintOutput = (stdout) => {
|
|
|
2245
2734
|
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
|
|
2246
2735
|
}
|
|
2247
2736
|
if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
|
|
2248
|
-
return parsed.diagnostics.filter((diagnostic) => diagnostic.code &&
|
|
2737
|
+
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
2249
2738
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
2250
2739
|
const primaryLabel = diagnostic.labels[0];
|
|
2251
2740
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
@@ -2275,8 +2764,8 @@ const validateRuleRegistration = () => {
|
|
|
2275
2764
|
const missingCategory = [];
|
|
2276
2765
|
for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
|
|
2277
2766
|
const ruleName = fullKey.replace(/^react-doctor\//, "");
|
|
2278
|
-
if (!(fullKey
|
|
2279
|
-
if (!(ruleName
|
|
2767
|
+
if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
|
|
2768
|
+
if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
|
|
2280
2769
|
}
|
|
2281
2770
|
if (missingCategory.length > 0 || missingHelp.length > 0) {
|
|
2282
2771
|
const detail = [missingCategory.length > 0 ? `Missing RULE_CATEGORY_MAP entries: ${missingCategory.join(", ")}` : null, missingHelp.length > 0 ? `Missing RULE_HELP_MAP entries: ${missingHelp.join(", ")}` : null].filter((entry) => entry !== null).join("; ");
|
|
@@ -2284,26 +2773,24 @@ const validateRuleRegistration = () => {
|
|
|
2284
2773
|
}
|
|
2285
2774
|
};
|
|
2286
2775
|
const runOxlint = async (options) => {
|
|
2287
|
-
const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
|
|
2776
|
+
const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, reactMajorVersion = null, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true } = options;
|
|
2288
2777
|
validateRuleRegistration();
|
|
2289
2778
|
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
2290
2779
|
const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
|
|
2291
2780
|
const configPath = path.join(configDirectory, "oxlintrc.json");
|
|
2781
|
+
const pluginPath = resolvePluginPath();
|
|
2782
|
+
const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
|
|
2292
2783
|
const config = createOxlintConfig({
|
|
2293
|
-
pluginPath
|
|
2784
|
+
pluginPath,
|
|
2294
2785
|
framework,
|
|
2295
2786
|
hasReactCompiler,
|
|
2296
2787
|
hasTanStackQuery,
|
|
2297
|
-
customRulesOnly
|
|
2788
|
+
customRulesOnly,
|
|
2789
|
+
reactMajorVersion,
|
|
2790
|
+
extendsPaths
|
|
2298
2791
|
});
|
|
2299
2792
|
const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
|
|
2300
2793
|
try {
|
|
2301
|
-
const fileHandle = fs.openSync(configPath, "wx", 384);
|
|
2302
|
-
try {
|
|
2303
|
-
fs.writeFileSync(fileHandle, JSON.stringify(config));
|
|
2304
|
-
} finally {
|
|
2305
|
-
fs.closeSync(fileHandle);
|
|
2306
|
-
}
|
|
2307
2794
|
const baseArgs = [
|
|
2308
2795
|
resolveOxlintBinary(),
|
|
2309
2796
|
"-c",
|
|
@@ -2322,12 +2809,41 @@ const runOxlint = async (options) => {
|
|
|
2322
2809
|
baseArgs.push("--ignore-path", combinedIgnorePath);
|
|
2323
2810
|
}
|
|
2324
2811
|
const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
|
|
2325
|
-
const
|
|
2326
|
-
|
|
2327
|
-
const
|
|
2328
|
-
|
|
2812
|
+
const writeOxlintConfig = (configToWrite) => {
|
|
2813
|
+
fs.rmSync(configPath, { force: true });
|
|
2814
|
+
const fileHandle = fs.openSync(configPath, "wx", 384);
|
|
2815
|
+
try {
|
|
2816
|
+
fs.writeFileSync(fileHandle, JSON.stringify(configToWrite));
|
|
2817
|
+
} finally {
|
|
2818
|
+
fs.closeSync(fileHandle);
|
|
2819
|
+
}
|
|
2820
|
+
};
|
|
2821
|
+
const spawnLintBatches = async () => {
|
|
2822
|
+
const allDiagnostics = [];
|
|
2823
|
+
for (const batch of fileBatches) {
|
|
2824
|
+
const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
|
|
2825
|
+
allDiagnostics.push(...parseOxlintOutput(stdout));
|
|
2826
|
+
}
|
|
2827
|
+
return allDiagnostics;
|
|
2828
|
+
};
|
|
2829
|
+
writeOxlintConfig(config);
|
|
2830
|
+
try {
|
|
2831
|
+
return await spawnLintBatches();
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
if (extendsPaths.length === 0) throw error;
|
|
2834
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
2835
|
+
process.stderr.write(`[react-doctor] could not adopt existing lint config (${reason.split("\n")[0]}); retrying without extends. Set "adoptExistingLintConfig": false to silence.\n`);
|
|
2836
|
+
writeOxlintConfig(createOxlintConfig({
|
|
2837
|
+
pluginPath,
|
|
2838
|
+
framework,
|
|
2839
|
+
hasReactCompiler,
|
|
2840
|
+
hasTanStackQuery,
|
|
2841
|
+
customRulesOnly,
|
|
2842
|
+
reactMajorVersion,
|
|
2843
|
+
extendsPaths: []
|
|
2844
|
+
}));
|
|
2845
|
+
return await spawnLintBatches();
|
|
2329
2846
|
}
|
|
2330
|
-
return allDiagnostics;
|
|
2331
2847
|
} finally {
|
|
2332
2848
|
restoreDisableDirectives();
|
|
2333
2849
|
fs.rmSync(configDirectory, {
|
|
@@ -2479,36 +2995,60 @@ const toJsonReport = (result, options) => buildJsonReport({
|
|
|
2479
2995
|
}],
|
|
2480
2996
|
totalElapsedMilliseconds: result.elapsedMilliseconds
|
|
2481
2997
|
});
|
|
2998
|
+
const EMPTY_DIAGNOSTICS = [];
|
|
2999
|
+
const settledOrEmpty = (settled, label) => {
|
|
3000
|
+
if (settled.status === "fulfilled") return settled.value;
|
|
3001
|
+
console.error(`${label} rejected:`, settled.reason);
|
|
3002
|
+
return EMPTY_DIAGNOSTICS;
|
|
3003
|
+
};
|
|
2482
3004
|
const diagnose = async (directory, options = {}) => {
|
|
3005
|
+
const startTime = globalThis.performance.now();
|
|
2483
3006
|
const resolvedDirectory = path.resolve(directory);
|
|
2484
3007
|
const userConfig = loadConfig(resolvedDirectory);
|
|
2485
3008
|
const includePaths = options.includePaths ?? [];
|
|
2486
3009
|
const isDiffMode = includePaths.length > 0;
|
|
3010
|
+
const projectInfo = discoverProject(resolvedDirectory);
|
|
3011
|
+
if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(resolvedDirectory));
|
|
2487
3012
|
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
|
|
2488
|
-
|
|
3013
|
+
const readFileLinesSync = createNodeReadFileLinesSync(resolvedDirectory);
|
|
3014
|
+
const effectiveLint = options.lint ?? userConfig?.lint ?? true;
|
|
3015
|
+
const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
|
|
3016
|
+
const effectiveRespectInlineDisables = options.respectInlineDisables ?? userConfig?.respectInlineDisables ?? true;
|
|
3017
|
+
const lintPromise = effectiveLint ? runOxlint({
|
|
2489
3018
|
rootDirectory: resolvedDirectory,
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
3019
|
+
hasTypeScript: projectInfo.hasTypeScript,
|
|
3020
|
+
framework: projectInfo.framework,
|
|
3021
|
+
hasReactCompiler: projectInfo.hasReactCompiler,
|
|
3022
|
+
hasTanStackQuery: projectInfo.hasTanStackQuery,
|
|
3023
|
+
reactMajorVersion: parseReactMajor(projectInfo.reactVersion),
|
|
3024
|
+
includePaths: lintIncludePaths,
|
|
3025
|
+
customRulesOnly: userConfig?.customRulesOnly ?? false,
|
|
3026
|
+
respectInlineDisables: effectiveRespectInlineDisables,
|
|
3027
|
+
adoptExistingLintConfig: userConfig?.adoptExistingLintConfig ?? true
|
|
3028
|
+
}).catch((error) => {
|
|
3029
|
+
console.error("Lint failed:", error);
|
|
3030
|
+
return EMPTY_DIAGNOSTICS;
|
|
3031
|
+
}) : Promise.resolve(EMPTY_DIAGNOSTICS);
|
|
3032
|
+
const deadCodePromise = effectiveDeadCode && !isDiffMode ? runKnip(resolvedDirectory).catch((error) => {
|
|
3033
|
+
console.error("Dead code analysis failed:", error);
|
|
3034
|
+
return EMPTY_DIAGNOSTICS;
|
|
3035
|
+
}) : Promise.resolve(EMPTY_DIAGNOSTICS);
|
|
3036
|
+
const [lintSettled, deadCodeSettled] = await Promise.allSettled([lintPromise, deadCodePromise]);
|
|
3037
|
+
const lintDiagnostics = settledOrEmpty(lintSettled, "Lint");
|
|
3038
|
+
const deadCodeDiagnostics = settledOrEmpty(deadCodeSettled, "Dead code");
|
|
3039
|
+
const environmentDiagnostics = isDiffMode ? [] : checkReducedMotion(resolvedDirectory);
|
|
3040
|
+
const diagnostics = mergeAndFilterDiagnostics([
|
|
3041
|
+
...lintDiagnostics,
|
|
3042
|
+
...deadCodeDiagnostics,
|
|
3043
|
+
...environmentDiagnostics
|
|
3044
|
+
], resolvedDirectory, userConfig, readFileLinesSync, { respectInlineDisables: effectiveRespectInlineDisables });
|
|
3045
|
+
const elapsedMilliseconds = globalThis.performance.now() - startTime;
|
|
3046
|
+
return {
|
|
3047
|
+
diagnostics,
|
|
3048
|
+
score: await calculateScore(diagnostics),
|
|
3049
|
+
project: projectInfo,
|
|
3050
|
+
elapsedMilliseconds
|
|
3051
|
+
};
|
|
2512
3052
|
};
|
|
2513
3053
|
//#endregion
|
|
2514
3054
|
export { buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, summarizeDiagnostics, toJsonReport };
|