react-doctor 0.0.47 → 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 +788 -108
- 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 +907 -397
- 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);
|
|
1052
|
-
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
1053
|
-
const clearConfigCache = () => {
|
|
1054
|
-
cachedConfigs.clear();
|
|
1050
|
+
const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1051
|
+
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
1052
|
+
const clearConfigCache = () => {
|
|
1053
|
+
cachedConfigs.clear();
|
|
1054
|
+
};
|
|
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
|
+
};
|
|
1055
1364
|
};
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
return
|
|
1063
|
-
}
|
|
1064
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
1065
|
-
cachedConfigs.set(rootDirectory, null);
|
|
1066
|
-
return null;
|
|
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 = {
|
|
@@ -1593,18 +1946,27 @@ const BUILTIN_A11Y_RULES = {
|
|
|
1593
1946
|
const GLOBAL_REACT_DOCTOR_RULES = {
|
|
1594
1947
|
"react-doctor/no-derived-state-effect": "warn",
|
|
1595
1948
|
"react-doctor/no-fetch-in-effect": "warn",
|
|
1949
|
+
"react-doctor/no-mirror-prop-effect": "warn",
|
|
1950
|
+
"react-doctor/no-mutable-in-deps": "error",
|
|
1596
1951
|
"react-doctor/no-cascading-set-state": "warn",
|
|
1952
|
+
"react-doctor/no-effect-chain": "warn",
|
|
1597
1953
|
"react-doctor/no-effect-event-handler": "warn",
|
|
1598
1954
|
"react-doctor/no-effect-event-in-deps": "error",
|
|
1955
|
+
"react-doctor/no-event-trigger-state": "warn",
|
|
1599
1956
|
"react-doctor/no-prop-callback-in-effect": "warn",
|
|
1600
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",
|
|
1601
1961
|
"react-doctor/prefer-useReducer": "warn",
|
|
1962
|
+
"react-doctor/prefer-use-sync-external-store": "warn",
|
|
1602
1963
|
"react-doctor/rerender-lazy-state-init": "warn",
|
|
1603
1964
|
"react-doctor/rerender-functional-setstate": "warn",
|
|
1604
1965
|
"react-doctor/rerender-dependencies": "error",
|
|
1605
1966
|
"react-doctor/rerender-state-only-in-handlers": "warn",
|
|
1606
1967
|
"react-doctor/rerender-defer-reads-hook": "warn",
|
|
1607
1968
|
"react-doctor/advanced-event-handler-refs": "warn",
|
|
1969
|
+
"react-doctor/effect-needs-cleanup": "error",
|
|
1608
1970
|
"react-doctor/no-giant-component": "warn",
|
|
1609
1971
|
"react-doctor/no-render-in-render": "warn",
|
|
1610
1972
|
"react-doctor/no-many-boolean-props": "warn",
|
|
@@ -1612,6 +1974,10 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1612
1974
|
"react-doctor/no-render-prop-children": "warn",
|
|
1613
1975
|
"react-doctor/no-nested-component-definition": "error",
|
|
1614
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",
|
|
1615
1981
|
"react-doctor/no-usememo-simple-expression": "warn",
|
|
1616
1982
|
"react-doctor/no-layout-property-animation": "error",
|
|
1617
1983
|
"react-doctor/rerender-memo-with-default-value": "warn",
|
|
@@ -1660,6 +2026,7 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1660
2026
|
"react-doctor/rendering-conditional-render": "warn",
|
|
1661
2027
|
"react-doctor/rendering-svg-precision": "warn",
|
|
1662
2028
|
"react-doctor/no-prevent-default": "warn",
|
|
2029
|
+
"react-doctor/no-uncontrolled-input": "warn",
|
|
1663
2030
|
"react-doctor/no-document-start-view-transition": "warn",
|
|
1664
2031
|
"react-doctor/no-flush-sync": "warn",
|
|
1665
2032
|
"react-doctor/server-auth-actions": "error",
|
|
@@ -1687,6 +2054,14 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1687
2054
|
"react-doctor/no-disabled-zoom": "error",
|
|
1688
2055
|
"react-doctor/no-outline-none": "warn",
|
|
1689
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",
|
|
1690
2065
|
"react-doctor/async-parallel": "warn"
|
|
1691
2066
|
};
|
|
1692
2067
|
const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
@@ -1696,10 +2071,38 @@ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
|
1696
2071
|
...Object.keys(TANSTACK_START_RULES),
|
|
1697
2072
|
...Object.keys(TANSTACK_QUERY_RULES)
|
|
1698
2073
|
]);
|
|
1699
|
-
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 = [] }) => {
|
|
1700
2102
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
1701
2103
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
1702
2104
|
return {
|
|
2105
|
+
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
1703
2106
|
categories: {
|
|
1704
2107
|
correctness: "off",
|
|
1705
2108
|
suspicious: "off",
|
|
@@ -1715,7 +2118,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
|
|
|
1715
2118
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
1716
2119
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
1717
2120
|
...reactCompilerRules,
|
|
1718
|
-
...GLOBAL_REACT_DOCTOR_RULES,
|
|
2121
|
+
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
1719
2122
|
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
1720
2123
|
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
1721
2124
|
...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
|
|
@@ -1827,23 +2230,43 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
1827
2230
|
"react-hooks-js": "React Compiler",
|
|
1828
2231
|
"react-doctor": "Other",
|
|
1829
2232
|
"jsx-a11y": "Accessibility",
|
|
1830
|
-
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"
|
|
1831
2245
|
};
|
|
1832
2246
|
const RULE_CATEGORY_MAP = {
|
|
1833
2247
|
"react-doctor/no-derived-state-effect": "State & Effects",
|
|
1834
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",
|
|
1835
2251
|
"react-doctor/no-cascading-set-state": "State & Effects",
|
|
2252
|
+
"react-doctor/no-effect-chain": "State & Effects",
|
|
1836
2253
|
"react-doctor/no-effect-event-handler": "State & Effects",
|
|
1837
2254
|
"react-doctor/no-effect-event-in-deps": "State & Effects",
|
|
2255
|
+
"react-doctor/no-event-trigger-state": "State & Effects",
|
|
1838
2256
|
"react-doctor/no-prop-callback-in-effect": "State & Effects",
|
|
1839
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",
|
|
1840
2261
|
"react-doctor/prefer-useReducer": "State & Effects",
|
|
2262
|
+
"react-doctor/prefer-use-sync-external-store": "State & Effects",
|
|
1841
2263
|
"react-doctor/rerender-lazy-state-init": "Performance",
|
|
1842
2264
|
"react-doctor/rerender-functional-setstate": "Performance",
|
|
1843
2265
|
"react-doctor/rerender-dependencies": "State & Effects",
|
|
1844
2266
|
"react-doctor/rerender-state-only-in-handlers": "Performance",
|
|
1845
2267
|
"react-doctor/rerender-defer-reads-hook": "Performance",
|
|
1846
2268
|
"react-doctor/advanced-event-handler-refs": "Performance",
|
|
2269
|
+
"react-doctor/effect-needs-cleanup": "State & Effects",
|
|
1847
2270
|
"react-doctor/no-generic-handler-names": "Architecture",
|
|
1848
2271
|
"react-doctor/no-giant-component": "Architecture",
|
|
1849
2272
|
"react-doctor/no-many-boolean-props": "Architecture",
|
|
@@ -1852,6 +2275,10 @@ const RULE_CATEGORY_MAP = {
|
|
|
1852
2275
|
"react-doctor/no-render-in-render": "Architecture",
|
|
1853
2276
|
"react-doctor/no-nested-component-definition": "Correctness",
|
|
1854
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",
|
|
1855
2282
|
"react-doctor/no-usememo-simple-expression": "Performance",
|
|
1856
2283
|
"react-doctor/no-layout-property-animation": "Performance",
|
|
1857
2284
|
"react-doctor/rerender-memo-with-default-value": "Performance",
|
|
@@ -1885,6 +2312,7 @@ const RULE_CATEGORY_MAP = {
|
|
|
1885
2312
|
"react-doctor/rendering-conditional-render": "Correctness",
|
|
1886
2313
|
"react-doctor/rendering-svg-precision": "Performance",
|
|
1887
2314
|
"react-doctor/no-prevent-default": "Correctness",
|
|
2315
|
+
"react-doctor/no-uncontrolled-input": "Correctness",
|
|
1888
2316
|
"react-doctor/no-document-start-view-transition": "Correctness",
|
|
1889
2317
|
"react-doctor/no-flush-sync": "Performance",
|
|
1890
2318
|
"react-doctor/nextjs-no-img-element": "Next.js",
|
|
@@ -1934,6 +2362,14 @@ const RULE_CATEGORY_MAP = {
|
|
|
1934
2362
|
"react-doctor/no-disabled-zoom": "Accessibility",
|
|
1935
2363
|
"react-doctor/no-outline-none": "Accessibility",
|
|
1936
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",
|
|
1937
2373
|
"react-doctor/js-flatmap-filter": "Performance",
|
|
1938
2374
|
"react-doctor/js-combine-iterations": "Performance",
|
|
1939
2375
|
"react-doctor/js-tosorted-immutable": "Performance",
|
|
@@ -1991,10 +2427,18 @@ const RULE_CATEGORY_MAP = {
|
|
|
1991
2427
|
const RULE_HELP_MAP = {
|
|
1992
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",
|
|
1993
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",
|
|
1994
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",
|
|
1995
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",
|
|
1996
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",
|
|
1997
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",
|
|
1998
2442
|
"rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
|
|
1999
2443
|
"rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
|
|
2000
2444
|
"rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
|
|
@@ -2003,7 +2447,11 @@ const RULE_HELP_MAP = {
|
|
|
2003
2447
|
"no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
|
|
2004
2448
|
"no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
|
|
2005
2449
|
"no-many-boolean-props": "Split into compound components or named variants: `<Button.Primary />`, `<DialogConfirm />` instead of stacking `isPrimary`, `isConfirm` flags",
|
|
2006
|
-
"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+.",
|
|
2007
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",
|
|
2008
2456
|
"no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
|
|
2009
2457
|
"no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
|
|
@@ -2018,6 +2466,7 @@ const RULE_HELP_MAP = {
|
|
|
2018
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",
|
|
2019
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",
|
|
2020
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",
|
|
2021
2470
|
"async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
|
|
2022
2471
|
"async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
|
|
2023
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",
|
|
@@ -2064,9 +2513,18 @@ const RULE_HELP_MAP = {
|
|
|
2064
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",
|
|
2065
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",
|
|
2066
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",
|
|
2067
2524
|
"no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
|
|
2068
2525
|
"rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
|
|
2069
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",
|
|
2070
2528
|
"nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
|
|
2071
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",
|
|
2072
2530
|
"nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
|
|
@@ -2149,6 +2607,7 @@ const RULE_HELP_MAP = {
|
|
|
2149
2607
|
};
|
|
2150
2608
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
2151
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;
|
|
2152
2611
|
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
2153
2612
|
if (plugin === "react-hooks-js") return {
|
|
2154
2613
|
message: REACT_COMPILER_MESSAGE,
|
|
@@ -2156,7 +2615,7 @@ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
|
2156
2615
|
};
|
|
2157
2616
|
return {
|
|
2158
2617
|
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
2159
|
-
help: help || RULE_HELP_MAP
|
|
2618
|
+
help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
|
|
2160
2619
|
};
|
|
2161
2620
|
};
|
|
2162
2621
|
const parseRuleCode = (code) => {
|
|
@@ -2184,7 +2643,7 @@ const resolvePluginPath = () => {
|
|
|
2184
2643
|
return pluginPath;
|
|
2185
2644
|
};
|
|
2186
2645
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
2187
|
-
return RULE_CATEGORY_MAP
|
|
2646
|
+
return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
|
|
2188
2647
|
};
|
|
2189
2648
|
const SANITIZED_ENV = (() => {
|
|
2190
2649
|
const sanitized = {};
|
|
@@ -2275,7 +2734,7 @@ const parseOxlintOutput = (stdout) => {
|
|
|
2275
2734
|
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
|
|
2276
2735
|
}
|
|
2277
2736
|
if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
|
|
2278
|
-
return parsed.diagnostics.filter((diagnostic) => diagnostic.code &&
|
|
2737
|
+
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
2279
2738
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
2280
2739
|
const primaryLabel = diagnostic.labels[0];
|
|
2281
2740
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
@@ -2305,8 +2764,8 @@ const validateRuleRegistration = () => {
|
|
|
2305
2764
|
const missingCategory = [];
|
|
2306
2765
|
for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
|
|
2307
2766
|
const ruleName = fullKey.replace(/^react-doctor\//, "");
|
|
2308
|
-
if (!(fullKey
|
|
2309
|
-
if (!(ruleName
|
|
2767
|
+
if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
|
|
2768
|
+
if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
|
|
2310
2769
|
}
|
|
2311
2770
|
if (missingCategory.length > 0 || missingHelp.length > 0) {
|
|
2312
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("; ");
|
|
@@ -2314,26 +2773,24 @@ const validateRuleRegistration = () => {
|
|
|
2314
2773
|
}
|
|
2315
2774
|
};
|
|
2316
2775
|
const runOxlint = async (options) => {
|
|
2317
|
-
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;
|
|
2318
2777
|
validateRuleRegistration();
|
|
2319
2778
|
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
2320
2779
|
const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
|
|
2321
2780
|
const configPath = path.join(configDirectory, "oxlintrc.json");
|
|
2781
|
+
const pluginPath = resolvePluginPath();
|
|
2782
|
+
const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
|
|
2322
2783
|
const config = createOxlintConfig({
|
|
2323
|
-
pluginPath
|
|
2784
|
+
pluginPath,
|
|
2324
2785
|
framework,
|
|
2325
2786
|
hasReactCompiler,
|
|
2326
2787
|
hasTanStackQuery,
|
|
2327
|
-
customRulesOnly
|
|
2788
|
+
customRulesOnly,
|
|
2789
|
+
reactMajorVersion,
|
|
2790
|
+
extendsPaths
|
|
2328
2791
|
});
|
|
2329
2792
|
const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
|
|
2330
2793
|
try {
|
|
2331
|
-
const fileHandle = fs.openSync(configPath, "wx", 384);
|
|
2332
|
-
try {
|
|
2333
|
-
fs.writeFileSync(fileHandle, JSON.stringify(config));
|
|
2334
|
-
} finally {
|
|
2335
|
-
fs.closeSync(fileHandle);
|
|
2336
|
-
}
|
|
2337
2794
|
const baseArgs = [
|
|
2338
2795
|
resolveOxlintBinary(),
|
|
2339
2796
|
"-c",
|
|
@@ -2352,12 +2809,41 @@ const runOxlint = async (options) => {
|
|
|
2352
2809
|
baseArgs.push("--ignore-path", combinedIgnorePath);
|
|
2353
2810
|
}
|
|
2354
2811
|
const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
|
|
2355
|
-
const
|
|
2356
|
-
|
|
2357
|
-
const
|
|
2358
|
-
|
|
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();
|
|
2359
2846
|
}
|
|
2360
|
-
return allDiagnostics;
|
|
2361
2847
|
} finally {
|
|
2362
2848
|
restoreDisableDirectives();
|
|
2363
2849
|
fs.rmSync(configDirectory, {
|
|
@@ -2509,36 +2995,60 @@ const toJsonReport = (result, options) => buildJsonReport({
|
|
|
2509
2995
|
}],
|
|
2510
2996
|
totalElapsedMilliseconds: result.elapsedMilliseconds
|
|
2511
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
|
+
};
|
|
2512
3004
|
const diagnose = async (directory, options = {}) => {
|
|
3005
|
+
const startTime = globalThis.performance.now();
|
|
2513
3006
|
const resolvedDirectory = path.resolve(directory);
|
|
2514
3007
|
const userConfig = loadConfig(resolvedDirectory);
|
|
2515
3008
|
const includePaths = options.includePaths ?? [];
|
|
2516
3009
|
const isDiffMode = includePaths.length > 0;
|
|
3010
|
+
const projectInfo = discoverProject(resolvedDirectory);
|
|
3011
|
+
if (!projectInfo.reactVersion) throw new Error(buildNoReactDependencyError(resolvedDirectory));
|
|
2517
3012
|
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
|
|
2518
|
-
|
|
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({
|
|
2519
3018
|
rootDirectory: resolvedDirectory,
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
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
|
+
};
|
|
2542
3052
|
};
|
|
2543
3053
|
//#endregion
|
|
2544
3054
|
export { buildJsonReport, buildJsonReportError, clearCaches, diagnose, filterSourceFiles, getDiffInfo, summarizeDiagnostics, toJsonReport };
|