react-doctor 0.0.47 → 0.1.1
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 +124 -233
- package/dist/cli.js +941 -226
- 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 +908 -398
- 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
|
|
@@ -492,7 +487,9 @@ const highlighter = {
|
|
|
492
487
|
warn: pc.yellow,
|
|
493
488
|
info: pc.cyan,
|
|
494
489
|
success: pc.green,
|
|
495
|
-
dim: pc.dim
|
|
490
|
+
dim: pc.dim,
|
|
491
|
+
gray: pc.gray,
|
|
492
|
+
bold: pc.bold
|
|
496
493
|
};
|
|
497
494
|
const logger = {
|
|
498
495
|
error(...args) {
|
|
@@ -915,7 +912,7 @@ const hasCompilerInConfigFile = (filePath) => {
|
|
|
915
912
|
return REACT_COMPILER_ENABLED_FLAG_PATTERN.test(content) || REACT_COMPILER_PACKAGE_REFERENCE_PATTERN.test(content);
|
|
916
913
|
};
|
|
917
914
|
const hasCompilerInConfigFiles = (directory, filenames) => filenames.some((filename) => hasCompilerInConfigFile(path.join(directory, filename)));
|
|
918
|
-
const isProjectBoundary$
|
|
915
|
+
const isProjectBoundary$2 = (directory) => {
|
|
919
916
|
if (fs.existsSync(path.join(directory, ".git"))) return true;
|
|
920
917
|
return isMonorepoRoot(directory);
|
|
921
918
|
};
|
|
@@ -925,14 +922,14 @@ const detectReactCompiler = (directory, packageJson) => {
|
|
|
925
922
|
if (hasCompilerInConfigFiles(directory, BABEL_CONFIG_FILENAMES)) return true;
|
|
926
923
|
if (hasCompilerInConfigFiles(directory, VITE_CONFIG_FILENAMES)) return true;
|
|
927
924
|
if (hasCompilerInConfigFiles(directory, EXPO_APP_CONFIG_FILENAMES)) return true;
|
|
928
|
-
if (isProjectBoundary$
|
|
925
|
+
if (isProjectBoundary$2(directory)) return false;
|
|
929
926
|
let ancestorDirectory = path.dirname(directory);
|
|
930
927
|
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
931
928
|
const ancestorPackagePath = path.join(ancestorDirectory, "package.json");
|
|
932
929
|
if (isFile(ancestorPackagePath)) {
|
|
933
930
|
if (hasCompilerPackage(readPackageJson(ancestorPackagePath))) return true;
|
|
934
931
|
}
|
|
935
|
-
if (isProjectBoundary$
|
|
932
|
+
if (isProjectBoundary$2(ancestorDirectory)) return false;
|
|
936
933
|
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
937
934
|
}
|
|
938
935
|
return false;
|
|
@@ -986,6 +983,9 @@ const discoverProject = (directory) => {
|
|
|
986
983
|
return projectInfo;
|
|
987
984
|
};
|
|
988
985
|
//#endregion
|
|
986
|
+
//#region src/utils/jsx-include-paths.ts
|
|
987
|
+
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
988
|
+
//#endregion
|
|
989
989
|
//#region src/utils/validate-config-types.ts
|
|
990
990
|
const BOOLEAN_FIELD_NAMES = [
|
|
991
991
|
"lint",
|
|
@@ -993,22 +993,23 @@ const BOOLEAN_FIELD_NAMES = [
|
|
|
993
993
|
"verbose",
|
|
994
994
|
"customRulesOnly",
|
|
995
995
|
"share",
|
|
996
|
-
"respectInlineDisables"
|
|
996
|
+
"respectInlineDisables",
|
|
997
|
+
"adoptExistingLintConfig"
|
|
997
998
|
];
|
|
998
|
-
const warnConfigField = (message) => {
|
|
999
|
+
const warnConfigField$1 = (message) => {
|
|
999
1000
|
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1000
1001
|
};
|
|
1001
1002
|
const coerceMaybeBooleanString = (fieldName, value) => {
|
|
1002
1003
|
if (typeof value === "boolean" || value === void 0) return value;
|
|
1003
1004
|
if (value === "true") {
|
|
1004
|
-
warnConfigField(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
|
|
1005
|
+
warnConfigField$1(`config field "${fieldName}" is the string "true"; treating as boolean true.`);
|
|
1005
1006
|
return true;
|
|
1006
1007
|
}
|
|
1007
1008
|
if (value === "false") {
|
|
1008
|
-
warnConfigField(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
|
|
1009
|
+
warnConfigField$1(`config field "${fieldName}" is the string "false"; treating as boolean false.`);
|
|
1009
1010
|
return false;
|
|
1010
1011
|
}
|
|
1011
|
-
warnConfigField(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1012
|
+
warnConfigField$1(`config field "${fieldName}" must be a boolean (got ${typeof value}); ignoring this field.`);
|
|
1012
1013
|
};
|
|
1013
1014
|
const validateConfigTypes = (config) => {
|
|
1014
1015
|
const validated = { ...config };
|
|
@@ -1048,38 +1049,383 @@ const loadConfigFromDirectory = (directory) => {
|
|
|
1048
1049
|
}
|
|
1049
1050
|
return null;
|
|
1050
1051
|
};
|
|
1051
|
-
const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1052
|
-
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
1053
|
-
const clearConfigCache = () => {
|
|
1054
|
-
cachedConfigs.clear();
|
|
1052
|
+
const isProjectBoundary$1 = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1053
|
+
const cachedConfigs = /* @__PURE__ */ new Map();
|
|
1054
|
+
const clearConfigCache = () => {
|
|
1055
|
+
cachedConfigs.clear();
|
|
1056
|
+
};
|
|
1057
|
+
const loadConfig = (rootDirectory) => {
|
|
1058
|
+
const cached = cachedConfigs.get(rootDirectory);
|
|
1059
|
+
if (cached !== void 0) return cached;
|
|
1060
|
+
const localConfig = loadConfigFromDirectory(rootDirectory);
|
|
1061
|
+
if (localConfig) {
|
|
1062
|
+
cachedConfigs.set(rootDirectory, localConfig);
|
|
1063
|
+
return localConfig;
|
|
1064
|
+
}
|
|
1065
|
+
if (isProjectBoundary$1(rootDirectory)) {
|
|
1066
|
+
cachedConfigs.set(rootDirectory, null);
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
let ancestorDirectory = path.dirname(rootDirectory);
|
|
1070
|
+
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
1071
|
+
const ancestorConfig = loadConfigFromDirectory(ancestorDirectory);
|
|
1072
|
+
if (ancestorConfig) {
|
|
1073
|
+
cachedConfigs.set(rootDirectory, ancestorConfig);
|
|
1074
|
+
return ancestorConfig;
|
|
1075
|
+
}
|
|
1076
|
+
if (isProjectBoundary$1(ancestorDirectory)) {
|
|
1077
|
+
cachedConfigs.set(rootDirectory, null);
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
1081
|
+
}
|
|
1082
|
+
cachedConfigs.set(rootDirectory, null);
|
|
1083
|
+
return null;
|
|
1084
|
+
};
|
|
1085
|
+
//#endregion
|
|
1086
|
+
//#region src/utils/match-glob-pattern.ts
|
|
1087
|
+
const REGEX_SPECIAL_CHARACTERS = /[.+^${}()|[\]\\]/g;
|
|
1088
|
+
const compileGlobPattern = (pattern) => {
|
|
1089
|
+
const normalizedPattern = pattern.replace(/\\/g, "/").replace(/^\//, "");
|
|
1090
|
+
let regexSource = "^";
|
|
1091
|
+
let characterIndex = 0;
|
|
1092
|
+
while (characterIndex < normalizedPattern.length) if (normalizedPattern[characterIndex] === "*" && normalizedPattern[characterIndex + 1] === "*") if (normalizedPattern[characterIndex + 2] === "/") {
|
|
1093
|
+
regexSource += "(?:.+/)?";
|
|
1094
|
+
characterIndex += 3;
|
|
1095
|
+
} else {
|
|
1096
|
+
regexSource += ".*";
|
|
1097
|
+
characterIndex += 2;
|
|
1098
|
+
}
|
|
1099
|
+
else if (normalizedPattern[characterIndex] === "*") {
|
|
1100
|
+
regexSource += "[^/]*";
|
|
1101
|
+
characterIndex++;
|
|
1102
|
+
} else if (normalizedPattern[characterIndex] === "?") {
|
|
1103
|
+
regexSource += "[^/]";
|
|
1104
|
+
characterIndex++;
|
|
1105
|
+
} else {
|
|
1106
|
+
regexSource += normalizedPattern[characterIndex].replace(REGEX_SPECIAL_CHARACTERS, "\\$&");
|
|
1107
|
+
characterIndex++;
|
|
1108
|
+
}
|
|
1109
|
+
regexSource += "$";
|
|
1110
|
+
return new RegExp(regexSource);
|
|
1111
|
+
};
|
|
1112
|
+
//#endregion
|
|
1113
|
+
//#region src/utils/to-relative-path.ts
|
|
1114
|
+
const toRelativePath = (filePath, rootDirectory) => {
|
|
1115
|
+
const normalizedFilePath = filePath.replace(/\\/g, "/");
|
|
1116
|
+
const normalizedRoot = rootDirectory.replace(/\\/g, "/").replace(/\/$/, "") + "/";
|
|
1117
|
+
if (normalizedFilePath.startsWith(normalizedRoot)) return normalizedFilePath.slice(normalizedRoot.length);
|
|
1118
|
+
return normalizedFilePath.replace(/^\.\//, "");
|
|
1119
|
+
};
|
|
1120
|
+
//#endregion
|
|
1121
|
+
//#region src/utils/apply-ignore-overrides.ts
|
|
1122
|
+
const warnConfigField = (message) => {
|
|
1123
|
+
process.stderr.write(`[react-doctor] ${message}\n`);
|
|
1124
|
+
};
|
|
1125
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
1126
|
+
const collectStringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1127
|
+
const validateOverrideEntry = (entry, index) => {
|
|
1128
|
+
if (!isPlainObject(entry)) {
|
|
1129
|
+
warnConfigField(`ignore.overrides[${index}] must be an object with { files, rules }; ignoring this entry.`);
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
if (!isStringArray(entry.files)) {
|
|
1133
|
+
warnConfigField(`ignore.overrides[${index}].files must be an array of strings; ignoring this entry.`);
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
if (entry.rules !== void 0 && !isStringArray(entry.rules)) {
|
|
1137
|
+
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).`);
|
|
1138
|
+
return { files: entry.files };
|
|
1139
|
+
}
|
|
1140
|
+
return entry.rules === void 0 ? { files: entry.files } : {
|
|
1141
|
+
files: entry.files,
|
|
1142
|
+
rules: entry.rules
|
|
1143
|
+
};
|
|
1144
|
+
};
|
|
1145
|
+
const compileIgnoreOverrides = (userConfig) => {
|
|
1146
|
+
const overrides = userConfig?.ignore?.overrides;
|
|
1147
|
+
if (overrides === void 0) return [];
|
|
1148
|
+
if (!Array.isArray(overrides)) {
|
|
1149
|
+
warnConfigField(`ignore.overrides must be an array of { files, rules } entries; ignoring.`);
|
|
1150
|
+
return [];
|
|
1151
|
+
}
|
|
1152
|
+
return overrides.flatMap((entry, index) => {
|
|
1153
|
+
const validated = validateOverrideEntry(entry, index);
|
|
1154
|
+
if (!validated) return [];
|
|
1155
|
+
const filePatterns = collectStringList(validated.files).map(compileGlobPattern);
|
|
1156
|
+
if (filePatterns.length === 0) return [];
|
|
1157
|
+
return [{
|
|
1158
|
+
filePatterns,
|
|
1159
|
+
ruleIds: new Set(collectStringList(validated.rules))
|
|
1160
|
+
}];
|
|
1161
|
+
});
|
|
1162
|
+
};
|
|
1163
|
+
const isDiagnosticIgnoredByOverrides = (diagnostic, rootDirectory, overrides) => {
|
|
1164
|
+
if (overrides.length === 0) return false;
|
|
1165
|
+
const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory);
|
|
1166
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1167
|
+
return overrides.some((override) => override.filePatterns.some((pattern) => pattern.test(relativeFilePath)) && (override.ruleIds.size === 0 || override.ruleIds.has(ruleIdentifier)));
|
|
1168
|
+
};
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/utils/find-jsx-opener-span.ts
|
|
1171
|
+
const JSX_OPENER_TAG_PATTERN = /<[A-Za-z][\w.]*/g;
|
|
1172
|
+
const JSX_TAG_NAME_FOLLOW = /[A-Za-z]/;
|
|
1173
|
+
const isOpenerMatchInsideLineComment = (line, openerCharIndex) => {
|
|
1174
|
+
let stringDelimiter = null;
|
|
1175
|
+
for (let charIndex = 0; charIndex < openerCharIndex; charIndex++) {
|
|
1176
|
+
const character = line[charIndex];
|
|
1177
|
+
if (stringDelimiter !== null) {
|
|
1178
|
+
if (character === "\\") {
|
|
1179
|
+
charIndex++;
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (character === stringDelimiter) stringDelimiter = null;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
1186
|
+
stringDelimiter = character;
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
if (character === "/" && line[charIndex + 1] === "/") return true;
|
|
1190
|
+
}
|
|
1191
|
+
return false;
|
|
1192
|
+
};
|
|
1193
|
+
const findOpenerTagOnLine = (line) => {
|
|
1194
|
+
for (const match of line.matchAll(JSX_OPENER_TAG_PATTERN)) {
|
|
1195
|
+
if (match.index === void 0) continue;
|
|
1196
|
+
if (!isOpenerMatchInsideLineComment(line, match.index)) return { startCharIndex: match.index + match[0].length };
|
|
1197
|
+
}
|
|
1198
|
+
return null;
|
|
1199
|
+
};
|
|
1200
|
+
const findJsxOpenerSpan = (lines, openerLineIndex) => {
|
|
1201
|
+
const openerLine = lines[openerLineIndex];
|
|
1202
|
+
if (openerLine === void 0) return null;
|
|
1203
|
+
const opener = findOpenerTagOnLine(openerLine);
|
|
1204
|
+
if (!opener) return null;
|
|
1205
|
+
const lookaheadLimit = Math.min(lines.length, openerLineIndex + 32);
|
|
1206
|
+
let braceDepth = 0;
|
|
1207
|
+
let innerAngleDepth = 0;
|
|
1208
|
+
let stringDelimiter = null;
|
|
1209
|
+
for (let lineIndex = openerLineIndex; lineIndex < lookaheadLimit; lineIndex++) {
|
|
1210
|
+
const currentLine = lines[lineIndex];
|
|
1211
|
+
const startCharForLine = lineIndex === openerLineIndex ? opener.startCharIndex : 0;
|
|
1212
|
+
for (let charIndex = startCharForLine; charIndex < currentLine.length; charIndex++) {
|
|
1213
|
+
const character = currentLine[charIndex];
|
|
1214
|
+
if (stringDelimiter !== null) {
|
|
1215
|
+
if (character === "\\") {
|
|
1216
|
+
charIndex++;
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
if (character === stringDelimiter) stringDelimiter = null;
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (character === "\"" || character === "'" || character === "`") {
|
|
1223
|
+
stringDelimiter = character;
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
if (character === "{") {
|
|
1227
|
+
braceDepth++;
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
if (character === "}") {
|
|
1231
|
+
braceDepth--;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (braceDepth !== 0) continue;
|
|
1235
|
+
if (character === "<") {
|
|
1236
|
+
const followCharacter = currentLine[charIndex + 1];
|
|
1237
|
+
if (followCharacter !== void 0 && JSX_TAG_NAME_FOLLOW.test(followCharacter)) innerAngleDepth++;
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (character !== ">") continue;
|
|
1241
|
+
const previousCharacter = currentLine[charIndex - 1];
|
|
1242
|
+
const nextCharacter = currentLine[charIndex + 1];
|
|
1243
|
+
if (previousCharacter === "=" || nextCharacter === "=") continue;
|
|
1244
|
+
if (innerAngleDepth > 0) {
|
|
1245
|
+
innerAngleDepth--;
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
return lineIndex;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return null;
|
|
1252
|
+
};
|
|
1253
|
+
//#endregion
|
|
1254
|
+
//#region src/utils/find-enclosing-jsx-opener.ts
|
|
1255
|
+
const findEnclosingMultilineJsxOpenerStart = (lines, diagnosticLineIndex) => {
|
|
1256
|
+
for (let candidateIndex = diagnosticLineIndex - 1; candidateIndex >= 0 && diagnosticLineIndex - candidateIndex <= 32; candidateIndex--) {
|
|
1257
|
+
const openerCloseIndex = findJsxOpenerSpan(lines, candidateIndex);
|
|
1258
|
+
if (openerCloseIndex !== null && openerCloseIndex >= diagnosticLineIndex) return candidateIndex;
|
|
1259
|
+
}
|
|
1260
|
+
return null;
|
|
1261
|
+
};
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/utils/find-stacked-disable-comments.ts
|
|
1264
|
+
const DISABLE_NEXT_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-next-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1265
|
+
const findStackedDisableCommentsAbove = (lines, anchorIndex) => {
|
|
1266
|
+
const collected = [];
|
|
1267
|
+
let isStillInChain = true;
|
|
1268
|
+
for (let candidateIndex = anchorIndex - 1; candidateIndex >= 0 && anchorIndex - candidateIndex <= 10; candidateIndex--) {
|
|
1269
|
+
const candidateLine = lines[candidateIndex];
|
|
1270
|
+
if (candidateLine === void 0) break;
|
|
1271
|
+
const match = candidateLine.match(DISABLE_NEXT_LINE_PATTERN);
|
|
1272
|
+
if (match) {
|
|
1273
|
+
collected.push({
|
|
1274
|
+
commentLineIndex: candidateIndex,
|
|
1275
|
+
ruleList: match[1],
|
|
1276
|
+
isInChain: isStillInChain
|
|
1277
|
+
});
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
isStillInChain = false;
|
|
1281
|
+
}
|
|
1282
|
+
return collected;
|
|
1283
|
+
};
|
|
1284
|
+
//#endregion
|
|
1285
|
+
//#region src/utils/is-rule-listed-in-comment.ts
|
|
1286
|
+
const isRuleListedInComment = (ruleList, ruleId) => {
|
|
1287
|
+
if (!ruleList?.trim()) return true;
|
|
1288
|
+
return ruleList.split(/[,\s]+/).some((token) => token.trim() === ruleId);
|
|
1289
|
+
};
|
|
1290
|
+
//#endregion
|
|
1291
|
+
//#region src/utils/evaluate-suppression.ts
|
|
1292
|
+
const DISABLE_LINE_PATTERN = /(?:\/\/|\/\*)\s*react-doctor-disable-line\b(?:\s+([\w/\-.,\s]+?))?\s*(?:\*\/)?\s*\}?\s*$/;
|
|
1293
|
+
const formatLineGap = (gapLineCount) => `${gapLineCount} line${gapLineCount === 1 ? "" : "s"}`;
|
|
1294
|
+
const hasChainSuppressor = (comments, ruleId) => comments.some((comment) => comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1295
|
+
const findAdjacentRuleListMismatch = (comments, ruleId) => comments.find((comment) => comment.isInChain && Boolean(comment.ruleList?.trim()) && !isRuleListedInComment(comment.ruleList, ruleId));
|
|
1296
|
+
const findOutOfChainMatch = (comments, ruleId) => comments.find((comment) => !comment.isInChain && isRuleListedInComment(comment.ruleList, ruleId));
|
|
1297
|
+
const buildAdjacentMismatchHint = (comment, ruleId) => {
|
|
1298
|
+
const ruleListText = comment.ruleList?.trim() ?? "";
|
|
1299
|
+
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}`;
|
|
1300
|
+
};
|
|
1301
|
+
const buildGapHint = (comment, diagnosticLineIndex, ruleId) => {
|
|
1302
|
+
const commentLineNumber = comment.commentLineIndex + 1;
|
|
1303
|
+
const diagnosticLineNumber = diagnosticLineIndex + 1;
|
|
1304
|
+
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.`;
|
|
1305
|
+
};
|
|
1306
|
+
const classifyFromComments = (commentsByAnchor, diagnosticLineIndex, ruleId) => {
|
|
1307
|
+
for (const comments of commentsByAnchor) {
|
|
1308
|
+
const adjacentMismatch = findAdjacentRuleListMismatch(comments, ruleId);
|
|
1309
|
+
if (adjacentMismatch) return buildAdjacentMismatchHint(adjacentMismatch, ruleId);
|
|
1310
|
+
const outOfChainMatch = findOutOfChainMatch(comments, ruleId);
|
|
1311
|
+
if (outOfChainMatch) return buildGapHint(outOfChainMatch, diagnosticLineIndex, ruleId);
|
|
1312
|
+
}
|
|
1313
|
+
return null;
|
|
1314
|
+
};
|
|
1315
|
+
const evaluateSuppression = (lines, diagnosticLineIndex, ruleId) => {
|
|
1316
|
+
const sameLineMatch = lines[diagnosticLineIndex]?.match(DISABLE_LINE_PATTERN);
|
|
1317
|
+
if (sameLineMatch && isRuleListedInComment(sameLineMatch[1], ruleId)) return {
|
|
1318
|
+
isSuppressed: true,
|
|
1319
|
+
nearMissHint: null
|
|
1320
|
+
};
|
|
1321
|
+
const directComments = findStackedDisableCommentsAbove(lines, diagnosticLineIndex);
|
|
1322
|
+
if (hasChainSuppressor(directComments, ruleId)) return {
|
|
1323
|
+
isSuppressed: true,
|
|
1324
|
+
nearMissHint: null
|
|
1325
|
+
};
|
|
1326
|
+
const openerStartIndex = findEnclosingMultilineJsxOpenerStart(lines, diagnosticLineIndex);
|
|
1327
|
+
const openerComments = openerStartIndex !== null && openerStartIndex > 0 ? findStackedDisableCommentsAbove(lines, openerStartIndex) : [];
|
|
1328
|
+
if (hasChainSuppressor(openerComments, ruleId)) return {
|
|
1329
|
+
isSuppressed: true,
|
|
1330
|
+
nearMissHint: null
|
|
1331
|
+
};
|
|
1332
|
+
return {
|
|
1333
|
+
isSuppressed: false,
|
|
1334
|
+
nearMissHint: classifyFromComments([directComments, openerComments], diagnosticLineIndex, ruleId)
|
|
1335
|
+
};
|
|
1336
|
+
};
|
|
1337
|
+
//#endregion
|
|
1338
|
+
//#region src/utils/is-ignored-file.ts
|
|
1339
|
+
const compileIgnoredFilePatterns = (userConfig) => {
|
|
1340
|
+
const files = userConfig?.ignore?.files;
|
|
1341
|
+
if (!Array.isArray(files)) return [];
|
|
1342
|
+
return files.filter((entry) => typeof entry === "string").map(compileGlobPattern);
|
|
1343
|
+
};
|
|
1344
|
+
const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
|
|
1345
|
+
if (patterns.length === 0) return false;
|
|
1346
|
+
const relativePath = toRelativePath(filePath, rootDirectory);
|
|
1347
|
+
return patterns.some((pattern) => pattern.test(relativePath));
|
|
1348
|
+
};
|
|
1349
|
+
//#endregion
|
|
1350
|
+
//#region src/utils/filter-diagnostics.ts
|
|
1351
|
+
const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
|
|
1352
|
+
const resolveCandidateReadPath = (rootDirectory, filePath) => {
|
|
1353
|
+
const normalizedFile = filePath.replace(/\\/g, "/");
|
|
1354
|
+
if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
|
|
1355
|
+
return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
|
|
1356
|
+
};
|
|
1357
|
+
const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
|
|
1358
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1359
|
+
return (filePath) => {
|
|
1360
|
+
const cached = cache.get(filePath);
|
|
1361
|
+
if (cached !== void 0) return cached;
|
|
1362
|
+
const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
|
|
1363
|
+
cache.set(filePath, lines);
|
|
1364
|
+
return lines;
|
|
1365
|
+
};
|
|
1055
1366
|
};
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
return
|
|
1063
|
-
}
|
|
1064
|
-
if (isProjectBoundary(rootDirectory)) {
|
|
1065
|
-
cachedConfigs.set(rootDirectory, null);
|
|
1066
|
-
return null;
|
|
1367
|
+
const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
|
|
1368
|
+
for (let lineIndex = diagnosticLine - 1; lineIndex >= 0; lineIndex--) {
|
|
1369
|
+
const match = lines[lineIndex].match(OPENING_TAG_PATTERN);
|
|
1370
|
+
if (!match) continue;
|
|
1371
|
+
const fullTagName = match[1];
|
|
1372
|
+
const leafTagName = fullTagName.includes(".") ? fullTagName.split(".").at(-1) ?? fullTagName : fullTagName;
|
|
1373
|
+
return textComponentNames.has(fullTagName) || textComponentNames.has(leafTagName);
|
|
1067
1374
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1375
|
+
return false;
|
|
1376
|
+
};
|
|
1377
|
+
const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
|
|
1378
|
+
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules.filter((rule) => typeof rule === "string") : []);
|
|
1379
|
+
const ignoredFilePatterns = compileIgnoredFilePatterns(config);
|
|
1380
|
+
const compiledOverrides = compileIgnoreOverrides(config);
|
|
1381
|
+
const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents.filter((name) => typeof name === "string") : []);
|
|
1382
|
+
const hasTextComponents = textComponentNames.size > 0;
|
|
1383
|
+
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
1384
|
+
return diagnostics.filter((diagnostic) => {
|
|
1385
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1386
|
+
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
1387
|
+
if (isFileIgnoredByPatterns(diagnostic.filePath, rootDirectory, ignoredFilePatterns)) return false;
|
|
1388
|
+
if (isDiagnosticIgnoredByOverrides(diagnostic, rootDirectory, compiledOverrides)) return false;
|
|
1389
|
+
if (hasTextComponents && diagnostic.rule === "rn-no-raw-text" && diagnostic.line > 0) {
|
|
1390
|
+
const lines = getFileLines(diagnostic.filePath);
|
|
1391
|
+
if (lines && isInsideTextComponent(lines, diagnostic.line, textComponentNames)) return false;
|
|
1078
1392
|
}
|
|
1079
|
-
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1393
|
+
return true;
|
|
1394
|
+
});
|
|
1395
|
+
};
|
|
1396
|
+
const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
|
|
1397
|
+
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
1398
|
+
return diagnostics.flatMap((diagnostic) => {
|
|
1399
|
+
if (diagnostic.line <= 0) return [diagnostic];
|
|
1400
|
+
const lines = getFileLines(diagnostic.filePath);
|
|
1401
|
+
if (!lines) return [diagnostic];
|
|
1402
|
+
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
1403
|
+
const evaluation = evaluateSuppression(lines, diagnostic.line - 1, ruleIdentifier);
|
|
1404
|
+
if (evaluation.isSuppressed) return [];
|
|
1405
|
+
return evaluation.nearMissHint ? [{
|
|
1406
|
+
...diagnostic,
|
|
1407
|
+
suppressionHint: evaluation.nearMissHint
|
|
1408
|
+
}] : [diagnostic];
|
|
1409
|
+
});
|
|
1410
|
+
};
|
|
1411
|
+
//#endregion
|
|
1412
|
+
//#region src/utils/merge-and-filter-diagnostics.ts
|
|
1413
|
+
const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync, options = {}) => {
|
|
1414
|
+
const filtered = userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics;
|
|
1415
|
+
if (options.respectInlineDisables === false) return filtered;
|
|
1416
|
+
return filterInlineSuppressions(filtered, directory, readFileLinesSync);
|
|
1417
|
+
};
|
|
1418
|
+
//#endregion
|
|
1419
|
+
//#region src/utils/parse-react-major.ts
|
|
1420
|
+
const parseReactMajor = (reactVersion) => {
|
|
1421
|
+
if (typeof reactVersion !== "string") return null;
|
|
1422
|
+
const trimmed = reactVersion.trim();
|
|
1423
|
+
if (trimmed.length === 0) return null;
|
|
1424
|
+
const match = trimmed.match(/(\d+)/);
|
|
1425
|
+
if (!match) return null;
|
|
1426
|
+
const major = Number.parseInt(match[1], 10);
|
|
1427
|
+
if (!Number.isFinite(major) || major <= 0) return null;
|
|
1428
|
+
return major;
|
|
1083
1429
|
};
|
|
1084
1430
|
//#endregion
|
|
1085
1431
|
//#region src/utils/read-file-lines-node.ts
|
|
@@ -1138,116 +1484,6 @@ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
|
1138
1484
|
});
|
|
1139
1485
|
};
|
|
1140
1486
|
//#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
1487
|
//#region src/utils/collect-unused-file-paths.ts
|
|
1252
1488
|
const collectUnusedFilePaths = (filesIssues) => {
|
|
1253
1489
|
if (filesIssues instanceof Set) return [...filesIssues];
|
|
@@ -1277,36 +1513,57 @@ const extractFailedPluginName = (error) => {
|
|
|
1277
1513
|
//#region src/utils/has-knip-config.ts
|
|
1278
1514
|
const hasKnipConfig = (directory) => KNIP_CONFIG_LOCATIONS.some((configFilename) => isFile(path.join(directory, configFilename)));
|
|
1279
1515
|
//#endregion
|
|
1516
|
+
//#region src/utils/sanitize-knip-config-patterns.ts
|
|
1517
|
+
const isMeaningfulPattern = (value) => typeof value !== "string" || value.trim().length > 0;
|
|
1518
|
+
const sanitizeStringArray = (values) => values.filter((entry) => typeof entry === "string" ? entry.trim().length > 0 : true);
|
|
1519
|
+
const sanitizeKnipConfigPatterns = (parsedConfig) => {
|
|
1520
|
+
for (const [key, value] of Object.entries(parsedConfig)) {
|
|
1521
|
+
if (typeof value === "string") {
|
|
1522
|
+
if (!isMeaningfulPattern(value)) delete parsedConfig[key];
|
|
1523
|
+
continue;
|
|
1524
|
+
}
|
|
1525
|
+
if (Array.isArray(value)) {
|
|
1526
|
+
if (value.length === 0) continue;
|
|
1527
|
+
const sanitized = sanitizeStringArray(value);
|
|
1528
|
+
if (sanitized.length === value.length) continue;
|
|
1529
|
+
if (sanitized.length === 0) delete parsedConfig[key];
|
|
1530
|
+
else parsedConfig[key] = sanitized;
|
|
1531
|
+
continue;
|
|
1532
|
+
}
|
|
1533
|
+
if (isPlainObject(value)) sanitizeKnipConfigPatterns(value);
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
//#endregion
|
|
1280
1537
|
//#region src/utils/run-knip.ts
|
|
1281
|
-
const KNIP_ISSUE_TYPE_DESCRIPTORS =
|
|
1282
|
-
files
|
|
1538
|
+
const KNIP_ISSUE_TYPE_DESCRIPTORS = new Map([
|
|
1539
|
+
["files", {
|
|
1283
1540
|
category: "Dead Code",
|
|
1284
1541
|
message: "Unused file",
|
|
1285
1542
|
severity: "warning"
|
|
1286
|
-
},
|
|
1287
|
-
exports
|
|
1543
|
+
}],
|
|
1544
|
+
["exports", {
|
|
1288
1545
|
category: "Dead Code",
|
|
1289
1546
|
message: "Unused export",
|
|
1290
1547
|
severity: "warning"
|
|
1291
|
-
},
|
|
1292
|
-
types
|
|
1548
|
+
}],
|
|
1549
|
+
["types", {
|
|
1293
1550
|
category: "Dead Code",
|
|
1294
1551
|
message: "Unused type",
|
|
1295
1552
|
severity: "warning"
|
|
1296
|
-
},
|
|
1297
|
-
duplicates
|
|
1553
|
+
}],
|
|
1554
|
+
["duplicates", {
|
|
1298
1555
|
category: "Dead Code",
|
|
1299
1556
|
message: "Duplicate export",
|
|
1300
1557
|
severity: "warning"
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1558
|
+
}]
|
|
1559
|
+
]);
|
|
1303
1560
|
const FALLBACK_KNIP_DESCRIPTOR = {
|
|
1304
1561
|
category: "Dead Code",
|
|
1305
1562
|
message: "Issue",
|
|
1306
1563
|
severity: "warning"
|
|
1307
1564
|
};
|
|
1308
1565
|
const collectIssueRecords = (records, issueType, rootDirectory) => {
|
|
1309
|
-
const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS
|
|
1566
|
+
const descriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get(issueType) ?? FALLBACK_KNIP_DESCRIPTOR;
|
|
1310
1567
|
const diagnostics = [];
|
|
1311
1568
|
for (const issues of Object.values(records)) for (const issue of Object.values(issues)) diagnostics.push({
|
|
1312
1569
|
filePath: path.relative(rootDirectory, issue.filePath),
|
|
@@ -1344,7 +1601,7 @@ const TSCONFIG_FILENAMES$1 = ["tsconfig.base.json", "tsconfig.json"];
|
|
|
1344
1601
|
const resolveTsConfigFile = (directory) => TSCONFIG_FILENAMES$1.find((filename) => fs.existsSync(path.join(directory, filename)));
|
|
1345
1602
|
const tryDisableFailedPlugin = (error, parsedConfig, disabledPlugins) => {
|
|
1346
1603
|
const failedPlugin = extractFailedPluginName(error);
|
|
1347
|
-
if (!failedPlugin || !(failedPlugin
|
|
1604
|
+
if (!failedPlugin || !Object.hasOwn(parsedConfig, failedPlugin) || disabledPlugins.has(failedPlugin)) return false;
|
|
1348
1605
|
disabledPlugins.add(failedPlugin);
|
|
1349
1606
|
parsedConfig[failedPlugin] = false;
|
|
1350
1607
|
return true;
|
|
@@ -1358,6 +1615,7 @@ const runKnipWithOptions = async (knipCwd, workspaceName) => {
|
|
|
1358
1615
|
...tsConfigFile ? { tsConfigFile } : {}
|
|
1359
1616
|
}));
|
|
1360
1617
|
const parsedConfig = options.parsedConfig;
|
|
1618
|
+
sanitizeKnipConfigPatterns(parsedConfig);
|
|
1361
1619
|
const disabledPlugins = /* @__PURE__ */ new Set();
|
|
1362
1620
|
let lastKnipError;
|
|
1363
1621
|
for (let attempt = 0; attempt < 6; attempt++) try {
|
|
@@ -1389,7 +1647,7 @@ const runKnip = async (rootDirectory) => {
|
|
|
1389
1647
|
if (!(hasNodeModules(rootDirectory) || monorepoRoot !== null && hasNodeModules(monorepoRoot))) return [];
|
|
1390
1648
|
const { issues } = await runKnipForProject(rootDirectory, monorepoRoot);
|
|
1391
1649
|
const diagnostics = [];
|
|
1392
|
-
const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.files;
|
|
1650
|
+
const filesDescriptor = KNIP_ISSUE_TYPE_DESCRIPTORS.get("files") ?? FALLBACK_KNIP_DESCRIPTOR;
|
|
1393
1651
|
for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
|
|
1394
1652
|
filePath: path.relative(rootDirectory, unusedFilePath),
|
|
1395
1653
|
plugin: "knip",
|
|
@@ -1432,6 +1690,103 @@ const batchIncludePaths = (baseArgs, includePaths) => {
|
|
|
1432
1690
|
return batches;
|
|
1433
1691
|
};
|
|
1434
1692
|
//#endregion
|
|
1693
|
+
//#region src/utils/can-oxlint-extend-config.ts
|
|
1694
|
+
const EXTENDS_LOCAL_PATH_PREFIXES = [
|
|
1695
|
+
"./",
|
|
1696
|
+
"../",
|
|
1697
|
+
"/"
|
|
1698
|
+
];
|
|
1699
|
+
const isLocalPathExtend = (entry) => {
|
|
1700
|
+
for (const prefix of EXTENDS_LOCAL_PATH_PREFIXES) if (entry.startsWith(prefix)) return true;
|
|
1701
|
+
return false;
|
|
1702
|
+
};
|
|
1703
|
+
const stripJsoncComments = (raw) => {
|
|
1704
|
+
let result = "";
|
|
1705
|
+
let cursor = 0;
|
|
1706
|
+
let inString = false;
|
|
1707
|
+
let stringQuote = "";
|
|
1708
|
+
while (cursor < raw.length) {
|
|
1709
|
+
const character = raw[cursor];
|
|
1710
|
+
const nextCharacter = raw[cursor + 1];
|
|
1711
|
+
if (inString) {
|
|
1712
|
+
result += character;
|
|
1713
|
+
if (character === "\\" && cursor + 1 < raw.length) {
|
|
1714
|
+
result += nextCharacter;
|
|
1715
|
+
cursor += 2;
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
if (character === stringQuote) inString = false;
|
|
1719
|
+
cursor += 1;
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
if (character === "\"" || character === "'") {
|
|
1723
|
+
inString = true;
|
|
1724
|
+
stringQuote = character;
|
|
1725
|
+
result += character;
|
|
1726
|
+
cursor += 1;
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
if (character === "/" && nextCharacter === "/") {
|
|
1730
|
+
const lineEndIndex = raw.indexOf("\n", cursor);
|
|
1731
|
+
cursor = lineEndIndex === -1 ? raw.length : lineEndIndex;
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
if (character === "/" && nextCharacter === "*") {
|
|
1735
|
+
const blockEndIndex = raw.indexOf("*/", cursor + 2);
|
|
1736
|
+
cursor = blockEndIndex === -1 ? raw.length : blockEndIndex + 2;
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
result += character;
|
|
1740
|
+
cursor += 1;
|
|
1741
|
+
}
|
|
1742
|
+
return result;
|
|
1743
|
+
};
|
|
1744
|
+
const parseJsonOrJsonc = (raw) => {
|
|
1745
|
+
try {
|
|
1746
|
+
return JSON.parse(raw);
|
|
1747
|
+
} catch {
|
|
1748
|
+
return JSON.parse(stripJsoncComments(raw));
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
const canOxlintExtendConfig = (configPath) => {
|
|
1752
|
+
if (!configPath.endsWith(".eslintrc.json")) return true;
|
|
1753
|
+
let parsed;
|
|
1754
|
+
try {
|
|
1755
|
+
parsed = parseJsonOrJsonc(fs.readFileSync(configPath, "utf-8"));
|
|
1756
|
+
} catch {
|
|
1757
|
+
return true;
|
|
1758
|
+
}
|
|
1759
|
+
if (!isPlainObject(parsed)) return true;
|
|
1760
|
+
const extendsValue = parsed.extends;
|
|
1761
|
+
if (extendsValue === void 0 || extendsValue === null) return true;
|
|
1762
|
+
const extendsEntries = Array.isArray(extendsValue) ? extendsValue : [extendsValue];
|
|
1763
|
+
if (extendsEntries.length === 0) return true;
|
|
1764
|
+
return extendsEntries.some((entry) => typeof entry === "string" && isLocalPathExtend(entry));
|
|
1765
|
+
};
|
|
1766
|
+
//#endregion
|
|
1767
|
+
//#region src/utils/detect-user-lint-config.ts
|
|
1768
|
+
const findFirstLintConfigInDirectory = (directory) => {
|
|
1769
|
+
for (const filename of ADOPTABLE_LINT_CONFIG_FILENAMES) {
|
|
1770
|
+
const candidatePath = path.join(directory, filename);
|
|
1771
|
+
if (isFile(candidatePath)) return candidatePath;
|
|
1772
|
+
}
|
|
1773
|
+
return null;
|
|
1774
|
+
};
|
|
1775
|
+
const isProjectBoundary = (directory) => fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);
|
|
1776
|
+
const detectUserLintConfigPaths = (rootDirectory) => {
|
|
1777
|
+
const directLintConfig = findFirstLintConfigInDirectory(rootDirectory);
|
|
1778
|
+
if (directLintConfig) return [directLintConfig];
|
|
1779
|
+
if (isProjectBoundary(rootDirectory)) return [];
|
|
1780
|
+
let ancestorDirectory = path.dirname(rootDirectory);
|
|
1781
|
+
while (ancestorDirectory !== path.dirname(ancestorDirectory)) {
|
|
1782
|
+
const ancestorLintConfig = findFirstLintConfigInDirectory(ancestorDirectory);
|
|
1783
|
+
if (ancestorLintConfig) return [ancestorLintConfig];
|
|
1784
|
+
if (isProjectBoundary(ancestorDirectory)) return [];
|
|
1785
|
+
ancestorDirectory = path.dirname(ancestorDirectory);
|
|
1786
|
+
}
|
|
1787
|
+
return [];
|
|
1788
|
+
};
|
|
1789
|
+
//#endregion
|
|
1435
1790
|
//#region src/oxlint-config.ts
|
|
1436
1791
|
const esmRequire$1 = createRequire(import.meta.url);
|
|
1437
1792
|
const NEXTJS_RULES = {
|
|
@@ -1593,18 +1948,27 @@ const BUILTIN_A11Y_RULES = {
|
|
|
1593
1948
|
const GLOBAL_REACT_DOCTOR_RULES = {
|
|
1594
1949
|
"react-doctor/no-derived-state-effect": "warn",
|
|
1595
1950
|
"react-doctor/no-fetch-in-effect": "warn",
|
|
1951
|
+
"react-doctor/no-mirror-prop-effect": "warn",
|
|
1952
|
+
"react-doctor/no-mutable-in-deps": "error",
|
|
1596
1953
|
"react-doctor/no-cascading-set-state": "warn",
|
|
1954
|
+
"react-doctor/no-effect-chain": "warn",
|
|
1597
1955
|
"react-doctor/no-effect-event-handler": "warn",
|
|
1598
1956
|
"react-doctor/no-effect-event-in-deps": "error",
|
|
1957
|
+
"react-doctor/no-event-trigger-state": "warn",
|
|
1599
1958
|
"react-doctor/no-prop-callback-in-effect": "warn",
|
|
1600
1959
|
"react-doctor/no-derived-useState": "warn",
|
|
1960
|
+
"react-doctor/no-direct-state-mutation": "warn",
|
|
1961
|
+
"react-doctor/no-set-state-in-render": "warn",
|
|
1962
|
+
"react-doctor/prefer-use-effect-event": "warn",
|
|
1601
1963
|
"react-doctor/prefer-useReducer": "warn",
|
|
1964
|
+
"react-doctor/prefer-use-sync-external-store": "warn",
|
|
1602
1965
|
"react-doctor/rerender-lazy-state-init": "warn",
|
|
1603
1966
|
"react-doctor/rerender-functional-setstate": "warn",
|
|
1604
1967
|
"react-doctor/rerender-dependencies": "error",
|
|
1605
1968
|
"react-doctor/rerender-state-only-in-handlers": "warn",
|
|
1606
1969
|
"react-doctor/rerender-defer-reads-hook": "warn",
|
|
1607
1970
|
"react-doctor/advanced-event-handler-refs": "warn",
|
|
1971
|
+
"react-doctor/effect-needs-cleanup": "error",
|
|
1608
1972
|
"react-doctor/no-giant-component": "warn",
|
|
1609
1973
|
"react-doctor/no-render-in-render": "warn",
|
|
1610
1974
|
"react-doctor/no-many-boolean-props": "warn",
|
|
@@ -1612,6 +1976,10 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1612
1976
|
"react-doctor/no-render-prop-children": "warn",
|
|
1613
1977
|
"react-doctor/no-nested-component-definition": "error",
|
|
1614
1978
|
"react-doctor/react-compiler-destructure-method": "warn",
|
|
1979
|
+
"react-doctor/no-legacy-class-lifecycles": "error",
|
|
1980
|
+
"react-doctor/no-legacy-context-api": "error",
|
|
1981
|
+
"react-doctor/no-default-props": "warn",
|
|
1982
|
+
"react-doctor/no-react-dom-deprecated-apis": "warn",
|
|
1615
1983
|
"react-doctor/no-usememo-simple-expression": "warn",
|
|
1616
1984
|
"react-doctor/no-layout-property-animation": "error",
|
|
1617
1985
|
"react-doctor/rerender-memo-with-default-value": "warn",
|
|
@@ -1660,6 +2028,7 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1660
2028
|
"react-doctor/rendering-conditional-render": "warn",
|
|
1661
2029
|
"react-doctor/rendering-svg-precision": "warn",
|
|
1662
2030
|
"react-doctor/no-prevent-default": "warn",
|
|
2031
|
+
"react-doctor/no-uncontrolled-input": "warn",
|
|
1663
2032
|
"react-doctor/no-document-start-view-transition": "warn",
|
|
1664
2033
|
"react-doctor/no-flush-sync": "warn",
|
|
1665
2034
|
"react-doctor/server-auth-actions": "error",
|
|
@@ -1687,6 +2056,14 @@ const GLOBAL_REACT_DOCTOR_RULES = {
|
|
|
1687
2056
|
"react-doctor/no-disabled-zoom": "error",
|
|
1688
2057
|
"react-doctor/no-outline-none": "warn",
|
|
1689
2058
|
"react-doctor/no-long-transition-duration": "warn",
|
|
2059
|
+
"react-doctor/design-no-bold-heading": "warn",
|
|
2060
|
+
"react-doctor/design-no-redundant-padding-axes": "warn",
|
|
2061
|
+
"react-doctor/design-no-redundant-size-axes": "warn",
|
|
2062
|
+
"react-doctor/design-no-space-on-flex-children": "warn",
|
|
2063
|
+
"react-doctor/design-no-em-dash-in-jsx-text": "warn",
|
|
2064
|
+
"react-doctor/design-no-three-period-ellipsis": "warn",
|
|
2065
|
+
"react-doctor/design-no-default-tailwind-palette": "warn",
|
|
2066
|
+
"react-doctor/design-no-vague-button-label": "warn",
|
|
1690
2067
|
"react-doctor/async-parallel": "warn"
|
|
1691
2068
|
};
|
|
1692
2069
|
const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
@@ -1696,10 +2073,38 @@ const ALL_REACT_DOCTOR_RULE_KEYS = new Set([
|
|
|
1696
2073
|
...Object.keys(TANSTACK_START_RULES),
|
|
1697
2074
|
...Object.keys(TANSTACK_QUERY_RULES)
|
|
1698
2075
|
]);
|
|
1699
|
-
const
|
|
2076
|
+
const VERSION_GATED_RULE_IDS = new Map([
|
|
2077
|
+
["react-doctor/no-react19-deprecated-apis", {
|
|
2078
|
+
minMajor: 19,
|
|
2079
|
+
mode: "deprecation-warning"
|
|
2080
|
+
}],
|
|
2081
|
+
["react-doctor/no-default-props", {
|
|
2082
|
+
minMajor: 19,
|
|
2083
|
+
mode: "deprecation-warning"
|
|
2084
|
+
}],
|
|
2085
|
+
["react-doctor/no-react-dom-deprecated-apis", {
|
|
2086
|
+
minMajor: 18,
|
|
2087
|
+
mode: "deprecation-warning"
|
|
2088
|
+
}],
|
|
2089
|
+
["react-doctor/prefer-use-effect-event", {
|
|
2090
|
+
minMajor: 19,
|
|
2091
|
+
mode: "prefer-newer-api"
|
|
2092
|
+
}]
|
|
2093
|
+
]);
|
|
2094
|
+
const filterRulesByReactMajor = (rules, reactMajorVersion) => {
|
|
2095
|
+
return Object.fromEntries(Object.entries(rules).filter(([ruleKey]) => {
|
|
2096
|
+
const gate = VERSION_GATED_RULE_IDS.get(ruleKey);
|
|
2097
|
+
if (gate === void 0) return true;
|
|
2098
|
+
if (gate.mode === "deprecation-warning") return true;
|
|
2099
|
+
if (reactMajorVersion === null) return true;
|
|
2100
|
+
return reactMajorVersion >= gate.minMajor;
|
|
2101
|
+
}));
|
|
2102
|
+
};
|
|
2103
|
+
const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanStackQuery, customRulesOnly = false, reactMajorVersion = null, extendsPaths = [] }) => {
|
|
1700
2104
|
const reactHooksJsPlugin = resolveReactHooksJsPlugin(hasReactCompiler, customRulesOnly);
|
|
1701
2105
|
const reactCompilerRules = reactHooksJsPlugin ? filterRulesToAvailable(REACT_COMPILER_RULES, "react-hooks-js", reactHooksJsPlugin.availableRuleNames) : {};
|
|
1702
2106
|
return {
|
|
2107
|
+
...extendsPaths.length > 0 ? { extends: extendsPaths } : {},
|
|
1703
2108
|
categories: {
|
|
1704
2109
|
correctness: "off",
|
|
1705
2110
|
suspicious: "off",
|
|
@@ -1715,7 +2120,7 @@ const createOxlintConfig = ({ pluginPath, framework, hasReactCompiler, hasTanSta
|
|
|
1715
2120
|
...customRulesOnly ? {} : BUILTIN_REACT_RULES,
|
|
1716
2121
|
...customRulesOnly ? {} : BUILTIN_A11Y_RULES,
|
|
1717
2122
|
...reactCompilerRules,
|
|
1718
|
-
...GLOBAL_REACT_DOCTOR_RULES,
|
|
2123
|
+
...filterRulesByReactMajor(GLOBAL_REACT_DOCTOR_RULES, reactMajorVersion),
|
|
1719
2124
|
...framework === "nextjs" ? NEXTJS_RULES : {},
|
|
1720
2125
|
...framework === "expo" || framework === "react-native" ? REACT_NATIVE_RULES : {},
|
|
1721
2126
|
...framework === "tanstack-start" ? TANSTACK_START_RULES : {},
|
|
@@ -1827,23 +2232,43 @@ const PLUGIN_CATEGORY_MAP = {
|
|
|
1827
2232
|
"react-hooks-js": "React Compiler",
|
|
1828
2233
|
"react-doctor": "Other",
|
|
1829
2234
|
"jsx-a11y": "Accessibility",
|
|
1830
|
-
knip: "Dead Code"
|
|
2235
|
+
knip: "Dead Code",
|
|
2236
|
+
eslint: "Correctness",
|
|
2237
|
+
oxc: "Correctness",
|
|
2238
|
+
typescript: "Correctness",
|
|
2239
|
+
unicorn: "Correctness",
|
|
2240
|
+
import: "Bundle Size",
|
|
2241
|
+
promise: "Correctness",
|
|
2242
|
+
n: "Correctness",
|
|
2243
|
+
node: "Correctness",
|
|
2244
|
+
vitest: "Correctness",
|
|
2245
|
+
jest: "Correctness",
|
|
2246
|
+
nextjs: "Next.js"
|
|
1831
2247
|
};
|
|
1832
2248
|
const RULE_CATEGORY_MAP = {
|
|
1833
2249
|
"react-doctor/no-derived-state-effect": "State & Effects",
|
|
1834
2250
|
"react-doctor/no-fetch-in-effect": "State & Effects",
|
|
2251
|
+
"react-doctor/no-mirror-prop-effect": "State & Effects",
|
|
2252
|
+
"react-doctor/no-mutable-in-deps": "State & Effects",
|
|
1835
2253
|
"react-doctor/no-cascading-set-state": "State & Effects",
|
|
2254
|
+
"react-doctor/no-effect-chain": "State & Effects",
|
|
1836
2255
|
"react-doctor/no-effect-event-handler": "State & Effects",
|
|
1837
2256
|
"react-doctor/no-effect-event-in-deps": "State & Effects",
|
|
2257
|
+
"react-doctor/no-event-trigger-state": "State & Effects",
|
|
1838
2258
|
"react-doctor/no-prop-callback-in-effect": "State & Effects",
|
|
1839
2259
|
"react-doctor/no-derived-useState": "State & Effects",
|
|
2260
|
+
"react-doctor/no-direct-state-mutation": "State & Effects",
|
|
2261
|
+
"react-doctor/no-set-state-in-render": "State & Effects",
|
|
2262
|
+
"react-doctor/prefer-use-effect-event": "State & Effects",
|
|
1840
2263
|
"react-doctor/prefer-useReducer": "State & Effects",
|
|
2264
|
+
"react-doctor/prefer-use-sync-external-store": "State & Effects",
|
|
1841
2265
|
"react-doctor/rerender-lazy-state-init": "Performance",
|
|
1842
2266
|
"react-doctor/rerender-functional-setstate": "Performance",
|
|
1843
2267
|
"react-doctor/rerender-dependencies": "State & Effects",
|
|
1844
2268
|
"react-doctor/rerender-state-only-in-handlers": "Performance",
|
|
1845
2269
|
"react-doctor/rerender-defer-reads-hook": "Performance",
|
|
1846
2270
|
"react-doctor/advanced-event-handler-refs": "Performance",
|
|
2271
|
+
"react-doctor/effect-needs-cleanup": "State & Effects",
|
|
1847
2272
|
"react-doctor/no-generic-handler-names": "Architecture",
|
|
1848
2273
|
"react-doctor/no-giant-component": "Architecture",
|
|
1849
2274
|
"react-doctor/no-many-boolean-props": "Architecture",
|
|
@@ -1852,6 +2277,10 @@ const RULE_CATEGORY_MAP = {
|
|
|
1852
2277
|
"react-doctor/no-render-in-render": "Architecture",
|
|
1853
2278
|
"react-doctor/no-nested-component-definition": "Correctness",
|
|
1854
2279
|
"react-doctor/react-compiler-destructure-method": "Architecture",
|
|
2280
|
+
"react-doctor/no-legacy-class-lifecycles": "Correctness",
|
|
2281
|
+
"react-doctor/no-legacy-context-api": "Correctness",
|
|
2282
|
+
"react-doctor/no-default-props": "Architecture",
|
|
2283
|
+
"react-doctor/no-react-dom-deprecated-apis": "Architecture",
|
|
1855
2284
|
"react-doctor/no-usememo-simple-expression": "Performance",
|
|
1856
2285
|
"react-doctor/no-layout-property-animation": "Performance",
|
|
1857
2286
|
"react-doctor/rerender-memo-with-default-value": "Performance",
|
|
@@ -1885,6 +2314,7 @@ const RULE_CATEGORY_MAP = {
|
|
|
1885
2314
|
"react-doctor/rendering-conditional-render": "Correctness",
|
|
1886
2315
|
"react-doctor/rendering-svg-precision": "Performance",
|
|
1887
2316
|
"react-doctor/no-prevent-default": "Correctness",
|
|
2317
|
+
"react-doctor/no-uncontrolled-input": "Correctness",
|
|
1888
2318
|
"react-doctor/no-document-start-view-transition": "Correctness",
|
|
1889
2319
|
"react-doctor/no-flush-sync": "Performance",
|
|
1890
2320
|
"react-doctor/nextjs-no-img-element": "Next.js",
|
|
@@ -1934,6 +2364,14 @@ const RULE_CATEGORY_MAP = {
|
|
|
1934
2364
|
"react-doctor/no-disabled-zoom": "Accessibility",
|
|
1935
2365
|
"react-doctor/no-outline-none": "Accessibility",
|
|
1936
2366
|
"react-doctor/no-long-transition-duration": "Performance",
|
|
2367
|
+
"react-doctor/design-no-bold-heading": "Architecture",
|
|
2368
|
+
"react-doctor/design-no-redundant-padding-axes": "Architecture",
|
|
2369
|
+
"react-doctor/design-no-redundant-size-axes": "Architecture",
|
|
2370
|
+
"react-doctor/design-no-space-on-flex-children": "Architecture",
|
|
2371
|
+
"react-doctor/design-no-em-dash-in-jsx-text": "Architecture",
|
|
2372
|
+
"react-doctor/design-no-three-period-ellipsis": "Architecture",
|
|
2373
|
+
"react-doctor/design-no-default-tailwind-palette": "Architecture",
|
|
2374
|
+
"react-doctor/design-no-vague-button-label": "Accessibility",
|
|
1937
2375
|
"react-doctor/js-flatmap-filter": "Performance",
|
|
1938
2376
|
"react-doctor/js-combine-iterations": "Performance",
|
|
1939
2377
|
"react-doctor/js-tosorted-immutable": "Performance",
|
|
@@ -1991,10 +2429,18 @@ const RULE_CATEGORY_MAP = {
|
|
|
1991
2429
|
const RULE_HELP_MAP = {
|
|
1992
2430
|
"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
2431
|
"no-fetch-in-effect": "Use `useQuery()` from @tanstack/react-query, `useSWR()`, or fetch in a Server Component instead",
|
|
2432
|
+
"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",
|
|
2433
|
+
"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
2434
|
"no-cascading-set-state": "Combine into useReducer: `const [state, dispatch] = useReducer(reducer, initialState)`",
|
|
2435
|
+
"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
2436
|
"no-effect-event-handler": "Move the conditional logic into onClick, onChange, or onSubmit handlers directly",
|
|
2437
|
+
"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
2438
|
"no-derived-useState": "Remove useState and compute the value inline: `const value = transform(propName)`",
|
|
2439
|
+
"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",
|
|
2440
|
+
"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",
|
|
2441
|
+
"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
2442
|
"prefer-useReducer": "Group related state: `const [state, dispatch] = useReducer(reducer, { field1, field2, ... })`",
|
|
2443
|
+
"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
2444
|
"rerender-lazy-state-init": "Wrap in an arrow function so it only runs once: `useState(() => expensiveComputation())`",
|
|
1999
2445
|
"rerender-functional-setstate": "Use the callback form: `setState(prev => prev + 1)` to always read the latest value",
|
|
2000
2446
|
"rerender-dependencies": "Extract to a useMemo, useRef, or module-level constant so the reference is stable",
|
|
@@ -2003,7 +2449,11 @@ const RULE_HELP_MAP = {
|
|
|
2003
2449
|
"no-generic-handler-names": "Rename to describe the action: e.g. `handleSubmit` → `saveUserProfile`, `handleClick` → `toggleSidebar`",
|
|
2004
2450
|
"no-giant-component": "Extract logical sections into focused components: `<UserHeader />`, `<UserActions />`, etc.",
|
|
2005
2451
|
"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.",
|
|
2452
|
+
"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+.",
|
|
2453
|
+
"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.",
|
|
2454
|
+
"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.",
|
|
2455
|
+
"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\" }`.",
|
|
2456
|
+
"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
2457
|
"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
2458
|
"no-render-in-render": "Extract to a named component: `const ListItem = ({ item }) => <div>{item.name}</div>`",
|
|
2009
2459
|
"no-nested-component-definition": "Move to a separate file or to module scope above the parent component",
|
|
@@ -2018,6 +2468,7 @@ const RULE_HELP_MAP = {
|
|
|
2018
2468
|
"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
2469
|
"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
2470
|
"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",
|
|
2471
|
+
"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
2472
|
"async-defer-await": "Move the `await` after the synchronous early-return guard so the skip path stays fast",
|
|
2022
2473
|
"async-await-in-loop": "Collect the items and use `await Promise.all(items.map(...))` to run independent operations concurrently",
|
|
2023
2474
|
"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 +2515,18 @@ const RULE_HELP_MAP = {
|
|
|
2064
2515
|
"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
2516
|
"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
2517
|
"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",
|
|
2518
|
+
"design-no-bold-heading": "Use `font-semibold` (600) or `font-medium` (500) on headings — 700+ crushes letter counter shapes at display sizes",
|
|
2519
|
+
"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`)",
|
|
2520
|
+
"design-no-redundant-size-axes": "Collapse `w-N h-N` to `size-N` (Tailwind v3.4+) when both axes match",
|
|
2521
|
+
"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",
|
|
2522
|
+
"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",
|
|
2523
|
+
"design-no-three-period-ellipsis": "Use the typographic ellipsis \"…\" (or `…`) instead of three periods — pairs with action-with-followup labels (\"Rename…\", \"Loading…\")",
|
|
2524
|
+
"design-no-default-tailwind-palette": "Replace `indigo-*` / `gray-*` / `slate-*` with project tokens, your brand color, or a less-default neutral (`zinc`, `neutral`, `stone`)",
|
|
2525
|
+
"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
2526
|
"no-array-index-as-key": "Use a stable unique identifier: `key={item.id}` or `key={item.slug}` — index keys break on reorder/filter",
|
|
2068
2527
|
"rendering-conditional-render": "Change to `{items.length > 0 && <List />}` or use a ternary: `{items.length ? <List /> : null}`",
|
|
2069
2528
|
"no-prevent-default": "Use `<form action={serverAction}>` (works without JS) or `<button>` instead of `<a>` with preventDefault",
|
|
2529
|
+
"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
2530
|
"nextjs-no-img-element": "`import Image from 'next/image'` — provides automatic WebP/AVIF, lazy loading, and responsive srcset",
|
|
2071
2531
|
"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
2532
|
"nextjs-no-a-element": "`import Link from 'next/link'` — enables client-side navigation, prefetching, and preserves scroll position",
|
|
@@ -2149,6 +2609,7 @@ const RULE_HELP_MAP = {
|
|
|
2149
2609
|
};
|
|
2150
2610
|
const FILEPATH_WITH_LOCATION_PATTERN = /\S+\.\w+:\d+:\d+[\s\S]*$/;
|
|
2151
2611
|
const REACT_COMPILER_MESSAGE = "React Compiler can't optimize this code";
|
|
2612
|
+
const lookupOwnString = (record, key) => Object.hasOwn(record, key) ? record[key] : void 0;
|
|
2152
2613
|
const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
2153
2614
|
if (plugin === "react-hooks-js") return {
|
|
2154
2615
|
message: REACT_COMPILER_MESSAGE,
|
|
@@ -2156,7 +2617,7 @@ const cleanDiagnosticMessage = (message, help, plugin, rule) => {
|
|
|
2156
2617
|
};
|
|
2157
2618
|
return {
|
|
2158
2619
|
message: message.replace(FILEPATH_WITH_LOCATION_PATTERN, "").trim() || message,
|
|
2159
|
-
help: help || RULE_HELP_MAP
|
|
2620
|
+
help: help || lookupOwnString(RULE_HELP_MAP, rule) || ""
|
|
2160
2621
|
};
|
|
2161
2622
|
};
|
|
2162
2623
|
const parseRuleCode = (code) => {
|
|
@@ -2184,7 +2645,7 @@ const resolvePluginPath = () => {
|
|
|
2184
2645
|
return pluginPath;
|
|
2185
2646
|
};
|
|
2186
2647
|
const resolveDiagnosticCategory = (plugin, rule) => {
|
|
2187
|
-
return RULE_CATEGORY_MAP
|
|
2648
|
+
return lookupOwnString(RULE_CATEGORY_MAP, `${plugin}/${rule}`) ?? lookupOwnString(PLUGIN_CATEGORY_MAP, plugin) ?? "Other";
|
|
2188
2649
|
};
|
|
2189
2650
|
const SANITIZED_ENV = (() => {
|
|
2190
2651
|
const sanitized = {};
|
|
@@ -2275,7 +2736,7 @@ const parseOxlintOutput = (stdout) => {
|
|
|
2275
2736
|
throw new Error(`Failed to parse oxlint output: ${stdout.slice(0, 200)}`);
|
|
2276
2737
|
}
|
|
2277
2738
|
if (!isOxlintOutput(parsed)) throw new Error(`Unexpected oxlint output shape: ${stdout.slice(0, 200)}`);
|
|
2278
|
-
return parsed.diagnostics.filter((diagnostic) => diagnostic.code &&
|
|
2739
|
+
return parsed.diagnostics.filter((diagnostic) => diagnostic.code && SOURCE_FILE_PATTERN.test(diagnostic.filename)).map((diagnostic) => {
|
|
2279
2740
|
const { plugin, rule } = parseRuleCode(diagnostic.code);
|
|
2280
2741
|
const primaryLabel = diagnostic.labels[0];
|
|
2281
2742
|
const cleaned = cleanDiagnosticMessage(diagnostic.message, diagnostic.help, plugin, rule);
|
|
@@ -2305,8 +2766,8 @@ const validateRuleRegistration = () => {
|
|
|
2305
2766
|
const missingCategory = [];
|
|
2306
2767
|
for (const fullKey of ALL_REACT_DOCTOR_RULE_KEYS) {
|
|
2307
2768
|
const ruleName = fullKey.replace(/^react-doctor\//, "");
|
|
2308
|
-
if (!(fullKey
|
|
2309
|
-
if (!(ruleName
|
|
2769
|
+
if (!Object.hasOwn(RULE_CATEGORY_MAP, fullKey)) missingCategory.push(fullKey);
|
|
2770
|
+
if (!Object.hasOwn(RULE_HELP_MAP, ruleName)) missingHelp.push(fullKey);
|
|
2310
2771
|
}
|
|
2311
2772
|
if (missingCategory.length > 0 || missingHelp.length > 0) {
|
|
2312
2773
|
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 +2775,24 @@ const validateRuleRegistration = () => {
|
|
|
2314
2775
|
}
|
|
2315
2776
|
};
|
|
2316
2777
|
const runOxlint = async (options) => {
|
|
2317
|
-
const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true } = options;
|
|
2778
|
+
const { rootDirectory, hasTypeScript, framework, hasReactCompiler, hasTanStackQuery, reactMajorVersion = null, includePaths, nodeBinaryPath = process.execPath, customRulesOnly = false, respectInlineDisables = true, adoptExistingLintConfig = true } = options;
|
|
2318
2779
|
validateRuleRegistration();
|
|
2319
2780
|
if (includePaths !== void 0 && includePaths.length === 0) return [];
|
|
2320
2781
|
const configDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-oxlintrc-"));
|
|
2321
2782
|
const configPath = path.join(configDirectory, "oxlintrc.json");
|
|
2783
|
+
const pluginPath = resolvePluginPath();
|
|
2784
|
+
const extendsPaths = (adoptExistingLintConfig && !customRulesOnly ? detectUserLintConfigPaths(rootDirectory) : []).filter(canOxlintExtendConfig);
|
|
2322
2785
|
const config = createOxlintConfig({
|
|
2323
|
-
pluginPath
|
|
2786
|
+
pluginPath,
|
|
2324
2787
|
framework,
|
|
2325
2788
|
hasReactCompiler,
|
|
2326
2789
|
hasTanStackQuery,
|
|
2327
|
-
customRulesOnly
|
|
2790
|
+
customRulesOnly,
|
|
2791
|
+
reactMajorVersion,
|
|
2792
|
+
extendsPaths
|
|
2328
2793
|
});
|
|
2329
2794
|
const restoreDisableDirectives = respectInlineDisables ? () => {} : neutralizeDisableDirectives(rootDirectory, includePaths);
|
|
2330
2795
|
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
2796
|
const baseArgs = [
|
|
2338
2797
|
resolveOxlintBinary(),
|
|
2339
2798
|
"-c",
|
|
@@ -2352,12 +2811,39 @@ const runOxlint = async (options) => {
|
|
|
2352
2811
|
baseArgs.push("--ignore-path", combinedIgnorePath);
|
|
2353
2812
|
}
|
|
2354
2813
|
const fileBatches = includePaths !== void 0 ? batchIncludePaths(baseArgs, includePaths) : [["."]];
|
|
2355
|
-
const
|
|
2356
|
-
|
|
2357
|
-
const
|
|
2358
|
-
|
|
2814
|
+
const writeOxlintConfig = (configToWrite) => {
|
|
2815
|
+
fs.rmSync(configPath, { force: true });
|
|
2816
|
+
const fileHandle = fs.openSync(configPath, "wx", 384);
|
|
2817
|
+
try {
|
|
2818
|
+
fs.writeFileSync(fileHandle, JSON.stringify(configToWrite));
|
|
2819
|
+
} finally {
|
|
2820
|
+
fs.closeSync(fileHandle);
|
|
2821
|
+
}
|
|
2822
|
+
};
|
|
2823
|
+
const spawnLintBatches = async () => {
|
|
2824
|
+
const allDiagnostics = [];
|
|
2825
|
+
for (const batch of fileBatches) {
|
|
2826
|
+
const stdout = await spawnOxlint([...baseArgs, ...batch], rootDirectory, nodeBinaryPath);
|
|
2827
|
+
allDiagnostics.push(...parseOxlintOutput(stdout));
|
|
2828
|
+
}
|
|
2829
|
+
return allDiagnostics;
|
|
2830
|
+
};
|
|
2831
|
+
writeOxlintConfig(config);
|
|
2832
|
+
try {
|
|
2833
|
+
return await spawnLintBatches();
|
|
2834
|
+
} catch (error) {
|
|
2835
|
+
if (extendsPaths.length === 0) throw error;
|
|
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 };
|