react-doctor 0.0.37 → 0.0.39
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 +22 -8
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +3 -0
- package/dist/cli.js +139 -86
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +347 -239
- package/dist/index.js.map +1 -1
- package/dist/process-browser-diagnostics-Cahx3_oy.d.ts +131 -0
- package/dist/process-browser-diagnostics-Cahx3_oy.d.ts.map +1 -0
- package/dist/process-browser-diagnostics-DpaZeYLI.js +365 -0
- package/dist/process-browser-diagnostics-DpaZeYLI.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.js +3 -0
- package/package.json +9 -1
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/utils/get-diff-files.ts","../src/index.ts"],"mappings":";KAAY,WAAA;AAAA,KAEA,SAAA;AAAA,UAWK,WAAA;EACf,aAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA,EAAW,SAAA;EACX,aAAA;EACA,gBAAA;EACA,eAAA;AAAA;AAAA,UAiCe,UAAA;EACf,QAAA;EACA,MAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,IAAA;EACA,MAAA;EACA,QAAA;EACA,MAAA;AAAA;AAAA,UA4Be,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAmBe,QAAA;EACf,aAAA;EACA,UAAA;EACA,YAAA;EACA,gBAAA;AAAA;AAAA,UA2Ce,uBAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EACf,MAAA,GAAS,uBAAA;EACT,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,MAAA,GAAS,WAAA;EACT,eAAA;EACA,KAAA;EACA,cAAA;AAAA;;;cC9FW,WAAA,GAAe,SAAA,UAAmB,kBAAA,cAA8B,QAAA;AAAA,cAiBhE,iBAAA,GAAqB,SAAA;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/utils/get-diff-files.ts","../src/index.ts"],"mappings":";KAAY,WAAA;AAAA,KAEA,SAAA;AAAA,UAWK,WAAA;EACf,aAAA;EACA,WAAA;EACA,YAAA;EACA,SAAA,EAAW,SAAA;EACX,aAAA;EACA,gBAAA;EACA,eAAA;AAAA;AAAA,UAiCe,UAAA;EACf,QAAA;EACA,MAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,IAAA;EACA,MAAA;EACA,QAAA;EACA,MAAA;AAAA;AAAA,UA4Be,WAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAmBe,QAAA;EACf,aAAA;EACA,UAAA;EACA,YAAA;EACA,gBAAA;AAAA;AAAA,UA2Ce,uBAAA;EACf,KAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EACf,MAAA,GAAS,uBAAA;EACT,IAAA;EACA,QAAA;EACA,OAAA;EACA,IAAA;EACA,MAAA,GAAS,WAAA;EACT,eAAA;EACA,KAAA;EACA,cAAA;AAAA;;;cC9FW,WAAA,GAAe,SAAA,UAAmB,kBAAA,cAA8B,QAAA;AAAA,cAiBhE,iBAAA,GAAqB,SAAA;;;UChFjB,eAAA;EACf,IAAA;EACA,QAAA;EACA,YAAA;AAAA;AAAA,UAGe,cAAA;EACf,WAAA,EAAa,UAAA;EACb,KAAA,EAAO,WAAA;EACP,OAAA,EAAS,WAAA;EACT,mBAAA;AAAA;AAAA,cAGW,QAAA,GACX,SAAA,UACA,OAAA,GAAS,eAAA,KACR,OAAA,CAAQ,cAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { performance } from "node:perf_hooks";
|
|
4
3
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
5
4
|
import fs from "node:fs";
|
|
6
5
|
import { main } from "knip";
|
|
@@ -8,194 +7,13 @@ import { createOptions } from "knip/session";
|
|
|
8
7
|
import os from "node:os";
|
|
9
8
|
import { fileURLToPath } from "node:url";
|
|
10
9
|
|
|
11
|
-
//#region src/
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
19
|
-
const FETCH_TIMEOUT_MS = 1e4;
|
|
20
|
-
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
21
|
-
const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
|
|
22
|
-
const OXLINT_MAX_FILES_PER_BATCH = 500;
|
|
23
|
-
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
24
|
-
const ERROR_RULE_PENALTY = 1.5;
|
|
25
|
-
const WARNING_RULE_PENALTY = .75;
|
|
26
|
-
const MAX_KNIP_RETRIES = 5;
|
|
27
|
-
const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
28
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
29
|
-
"node_modules",
|
|
30
|
-
"dist",
|
|
31
|
-
"build",
|
|
32
|
-
"coverage"
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
//#endregion
|
|
36
|
-
//#region src/utils/proxy-fetch.ts
|
|
37
|
-
const readNpmConfigValue = (key) => {
|
|
38
|
-
try {
|
|
39
|
-
const value = execSync(`npm config get ${key}`, {
|
|
40
|
-
encoding: "utf-8",
|
|
41
|
-
stdio: [
|
|
42
|
-
"pipe",
|
|
43
|
-
"pipe",
|
|
44
|
-
"ignore"
|
|
45
|
-
]
|
|
46
|
-
}).trim();
|
|
47
|
-
if (value && value !== "null" && value !== "undefined") return value;
|
|
48
|
-
} catch {}
|
|
49
|
-
};
|
|
50
|
-
const resolveProxyUrl = () => process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? readNpmConfigValue("https-proxy") ?? readNpmConfigValue("proxy");
|
|
51
|
-
let isProxyUrlResolved = false;
|
|
52
|
-
let resolvedProxyUrl;
|
|
53
|
-
const getProxyUrl = () => {
|
|
54
|
-
if (isProxyUrlResolved) return resolvedProxyUrl;
|
|
55
|
-
isProxyUrlResolved = true;
|
|
56
|
-
resolvedProxyUrl = resolveProxyUrl();
|
|
57
|
-
return resolvedProxyUrl;
|
|
58
|
-
};
|
|
59
|
-
const createProxyDispatcher = async (proxyUrl) => {
|
|
60
|
-
try {
|
|
61
|
-
const { ProxyAgent } = await import("undici");
|
|
62
|
-
return new ProxyAgent(proxyUrl);
|
|
63
|
-
} catch {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
const proxyFetch = async (url, init) => {
|
|
68
|
-
const controller = new AbortController();
|
|
69
|
-
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
70
|
-
try {
|
|
71
|
-
const proxyUrl = getProxyUrl();
|
|
72
|
-
const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
|
|
73
|
-
return await fetch(url, {
|
|
74
|
-
...init,
|
|
75
|
-
signal: controller.signal,
|
|
76
|
-
...dispatcher ? { dispatcher } : {}
|
|
77
|
-
});
|
|
78
|
-
} finally {
|
|
79
|
-
clearTimeout(timeoutId);
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
//#endregion
|
|
84
|
-
//#region src/utils/calculate-score.ts
|
|
85
|
-
const getScoreLabel = (score) => {
|
|
86
|
-
if (score >= SCORE_GOOD_THRESHOLD) return "Great";
|
|
87
|
-
if (score >= SCORE_OK_THRESHOLD) return "Needs work";
|
|
88
|
-
return "Critical";
|
|
89
|
-
};
|
|
90
|
-
const countUniqueRules = (diagnostics) => {
|
|
91
|
-
const errorRules = /* @__PURE__ */ new Set();
|
|
92
|
-
const warningRules = /* @__PURE__ */ new Set();
|
|
93
|
-
for (const diagnostic of diagnostics) {
|
|
94
|
-
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
95
|
-
if (diagnostic.severity === "error") errorRules.add(ruleKey);
|
|
96
|
-
else warningRules.add(ruleKey);
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
errorRuleCount: errorRules.size,
|
|
100
|
-
warningRuleCount: warningRules.size
|
|
101
|
-
};
|
|
102
|
-
};
|
|
103
|
-
const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
|
|
104
|
-
const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
|
|
105
|
-
return Math.max(0, Math.round(PERFECT_SCORE - penalty));
|
|
106
|
-
};
|
|
107
|
-
const calculateScoreLocally = (diagnostics) => {
|
|
108
|
-
const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
|
|
109
|
-
const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
|
|
110
|
-
return {
|
|
111
|
-
score,
|
|
112
|
-
label: getScoreLabel(score)
|
|
113
|
-
};
|
|
114
|
-
};
|
|
115
|
-
const calculateScore = async (diagnostics) => {
|
|
116
|
-
try {
|
|
117
|
-
const response = await proxyFetch(SCORE_API_URL, {
|
|
118
|
-
method: "POST",
|
|
119
|
-
headers: { "Content-Type": "application/json" },
|
|
120
|
-
body: JSON.stringify({ diagnostics })
|
|
121
|
-
});
|
|
122
|
-
if (!response.ok) return calculateScoreLocally(diagnostics);
|
|
123
|
-
return await response.json();
|
|
124
|
-
} catch {
|
|
125
|
-
return calculateScoreLocally(diagnostics);
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
//#endregion
|
|
130
|
-
//#region src/plugin/constants.ts
|
|
131
|
-
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
132
|
-
|
|
133
|
-
//#endregion
|
|
134
|
-
//#region src/utils/is-file.ts
|
|
135
|
-
const isFile = (filePath) => {
|
|
136
|
-
try {
|
|
137
|
-
return fs.statSync(filePath).isFile();
|
|
138
|
-
} catch {
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region src/utils/read-package-json.ts
|
|
145
|
-
const readPackageJson = (packageJsonPath) => {
|
|
146
|
-
try {
|
|
147
|
-
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
148
|
-
} catch (error) {
|
|
149
|
-
if (error instanceof SyntaxError) return {};
|
|
150
|
-
if (error instanceof Error && "code" in error) {
|
|
151
|
-
const { code } = error;
|
|
152
|
-
if (code === "EISDIR" || code === "EACCES") return {};
|
|
153
|
-
}
|
|
154
|
-
throw error;
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
//#endregion
|
|
159
|
-
//#region src/utils/check-reduced-motion.ts
|
|
160
|
-
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
|
|
161
|
-
const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
|
|
162
|
-
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
163
|
-
filePath: "package.json",
|
|
164
|
-
plugin: "react-doctor",
|
|
165
|
-
rule: "require-reduced-motion",
|
|
166
|
-
severity: "error",
|
|
167
|
-
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
168
|
-
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
169
|
-
line: 0,
|
|
170
|
-
column: 0,
|
|
171
|
-
category: "Accessibility",
|
|
172
|
-
weight: 2
|
|
173
|
-
};
|
|
174
|
-
const checkReducedMotion = (rootDirectory) => {
|
|
175
|
-
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
176
|
-
if (!isFile(packageJsonPath)) return [];
|
|
177
|
-
let hasMotionLibrary = false;
|
|
178
|
-
try {
|
|
179
|
-
const packageJson = readPackageJson(packageJsonPath);
|
|
180
|
-
const allDependencies = {
|
|
181
|
-
...packageJson.dependencies,
|
|
182
|
-
...packageJson.devDependencies
|
|
183
|
-
};
|
|
184
|
-
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
185
|
-
} catch {
|
|
186
|
-
return [];
|
|
187
|
-
}
|
|
188
|
-
if (!hasMotionLibrary) return [];
|
|
189
|
-
try {
|
|
190
|
-
execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
|
|
191
|
-
cwd: rootDirectory,
|
|
192
|
-
stdio: "pipe"
|
|
193
|
-
});
|
|
194
|
-
return [];
|
|
195
|
-
} catch {
|
|
196
|
-
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
197
|
-
}
|
|
198
|
-
};
|
|
10
|
+
//#region src/core/build-diagnose-result.ts
|
|
11
|
+
const buildDiagnoseResult = (params) => ({
|
|
12
|
+
diagnostics: params.diagnostics,
|
|
13
|
+
score: params.score,
|
|
14
|
+
project: params.project,
|
|
15
|
+
elapsedMilliseconds: params.elapsedMilliseconds
|
|
16
|
+
});
|
|
199
17
|
|
|
200
18
|
//#endregion
|
|
201
19
|
//#region src/utils/match-glob-pattern.ts
|
|
@@ -242,23 +60,22 @@ const isFileIgnoredByPatterns = (filePath, rootDirectory, patterns) => {
|
|
|
242
60
|
|
|
243
61
|
//#endregion
|
|
244
62
|
//#region src/utils/filter-diagnostics.ts
|
|
63
|
+
const resolveCandidateReadPath = (rootDirectory, filePath) => {
|
|
64
|
+
const normalizedFile = filePath.replace(/\\/g, "/");
|
|
65
|
+
if (normalizedFile.startsWith("/") || /^[a-zA-Z]:\//.test(normalizedFile) || /^[a-zA-Z]:\\/.test(filePath)) return filePath;
|
|
66
|
+
return `${rootDirectory.replace(/\\/g, "/").replace(/\/$/, "")}/${normalizedFile.replace(/^\.\//, "")}`;
|
|
67
|
+
};
|
|
245
68
|
const OPENING_TAG_PATTERN = /<([A-Z][\w.]*)/;
|
|
246
69
|
const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
|
|
247
70
|
const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
|
|
248
|
-
const createFileLinesCache = (rootDirectory) => {
|
|
71
|
+
const createFileLinesCache = (rootDirectory, readFileLinesSync) => {
|
|
249
72
|
const cache = /* @__PURE__ */ new Map();
|
|
250
73
|
return (filePath) => {
|
|
251
74
|
const cached = cache.get(filePath);
|
|
252
75
|
if (cached !== void 0) return cached;
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
cache.set(filePath, lines);
|
|
257
|
-
return lines;
|
|
258
|
-
} catch {
|
|
259
|
-
cache.set(filePath, null);
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
76
|
+
const lines = readFileLinesSync(resolveCandidateReadPath(rootDirectory, filePath));
|
|
77
|
+
cache.set(filePath, lines);
|
|
78
|
+
return lines;
|
|
262
79
|
};
|
|
263
80
|
};
|
|
264
81
|
const isInsideTextComponent = (lines, diagnosticLine, textComponentNames) => {
|
|
@@ -275,12 +92,12 @@ const isRuleSuppressed = (commentRules, ruleId) => {
|
|
|
275
92
|
if (!commentRules?.trim()) return true;
|
|
276
93
|
return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
|
|
277
94
|
};
|
|
278
|
-
const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory) => {
|
|
95
|
+
const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory, readFileLinesSync) => {
|
|
279
96
|
const ignoredRules = new Set(Array.isArray(config.ignore?.rules) ? config.ignore.rules : []);
|
|
280
97
|
const ignoredFilePatterns = compileIgnoredFilePatterns(config);
|
|
281
98
|
const textComponentNames = new Set(Array.isArray(config.textComponents) ? config.textComponents : []);
|
|
282
99
|
const hasTextComponents = textComponentNames.size > 0;
|
|
283
|
-
const getFileLines = createFileLinesCache(rootDirectory);
|
|
100
|
+
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
284
101
|
return diagnostics.filter((diagnostic) => {
|
|
285
102
|
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
286
103
|
if (ignoredRules.has(ruleIdentifier)) return false;
|
|
@@ -292,8 +109,8 @@ const filterIgnoredDiagnostics = (diagnostics, config, rootDirectory) => {
|
|
|
292
109
|
return true;
|
|
293
110
|
});
|
|
294
111
|
};
|
|
295
|
-
const filterInlineSuppressions = (diagnostics, rootDirectory) => {
|
|
296
|
-
const getFileLines = createFileLinesCache(rootDirectory);
|
|
112
|
+
const filterInlineSuppressions = (diagnostics, rootDirectory, readFileLinesSync) => {
|
|
113
|
+
const getFileLines = createFileLinesCache(rootDirectory, readFileLinesSync);
|
|
297
114
|
return diagnostics.filter((diagnostic) => {
|
|
298
115
|
if (diagnostic.line <= 0) return true;
|
|
299
116
|
const lines = getFileLines(diagnostic.filePath);
|
|
@@ -316,15 +133,172 @@ const filterInlineSuppressions = (diagnostics, rootDirectory) => {
|
|
|
316
133
|
};
|
|
317
134
|
|
|
318
135
|
//#endregion
|
|
319
|
-
//#region src/utils/
|
|
136
|
+
//#region src/utils/merge-and-filter-diagnostics.ts
|
|
137
|
+
const mergeAndFilterDiagnostics = (mergedDiagnostics, directory, userConfig, readFileLinesSync) => {
|
|
138
|
+
return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(mergedDiagnostics, userConfig, directory, readFileLinesSync) : mergedDiagnostics, directory, readFileLinesSync);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/core/build-result.ts
|
|
143
|
+
const buildDiagnoseTimedResult = async (input) => {
|
|
144
|
+
const diagnostics = mergeAndFilterDiagnostics(input.mergedDiagnostics, input.rootDirectory, input.userConfig, input.readFileLinesSync);
|
|
145
|
+
const elapsedMilliseconds = globalThis.performance.now() - input.startTime;
|
|
146
|
+
return {
|
|
147
|
+
diagnostics,
|
|
148
|
+
score: input.score !== void 0 ? input.score : await input.calculateDiagnosticsScore(diagnostics),
|
|
149
|
+
elapsedMilliseconds
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/constants.ts
|
|
155
|
+
const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
|
|
156
|
+
const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
|
|
157
|
+
const ERROR_PREVIEW_LENGTH_CHARS = 200;
|
|
158
|
+
const PERFECT_SCORE = 100;
|
|
159
|
+
const SCORE_GOOD_THRESHOLD = 75;
|
|
160
|
+
const SCORE_OK_THRESHOLD = 50;
|
|
161
|
+
const SCORE_API_URL = "https://www.react.doctor/api/score";
|
|
162
|
+
const FETCH_TIMEOUT_MS = 1e4;
|
|
163
|
+
const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
164
|
+
const SPAWN_ARGS_MAX_LENGTH_CHARS = 24e3;
|
|
165
|
+
const OXLINT_MAX_FILES_PER_BATCH = 500;
|
|
166
|
+
const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
|
|
167
|
+
const ERROR_RULE_PENALTY = 1.5;
|
|
168
|
+
const WARNING_RULE_PENALTY = .75;
|
|
169
|
+
const MAX_KNIP_RETRIES = 5;
|
|
170
|
+
const GIT_SHOW_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
171
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
172
|
+
"node_modules",
|
|
173
|
+
"dist",
|
|
174
|
+
"build",
|
|
175
|
+
"coverage"
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/utils/jsx-include-paths.ts
|
|
320
180
|
const computeJsxIncludePaths = (includePaths) => includePaths.length > 0 ? includePaths.filter((filePath) => JSX_FILE_PATTERN.test(filePath)) : void 0;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/core/diagnose-core.ts
|
|
184
|
+
const diagnoseCore = async (deps, options = {}) => {
|
|
185
|
+
const { includePaths = [] } = options;
|
|
186
|
+
const isDiffMode = includePaths.length > 0;
|
|
187
|
+
const startTime = globalThis.performance.now();
|
|
188
|
+
const resolvedDirectory = deps.rootDirectory;
|
|
189
|
+
const projectInfo = deps.discoverProjectInfo();
|
|
190
|
+
const userConfig = deps.loadUserConfig();
|
|
191
|
+
const effectiveLint = options.lint ?? userConfig?.lint ?? true;
|
|
192
|
+
const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
|
|
193
|
+
if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
|
|
194
|
+
const lintIncludePaths = options.lintIncludePaths !== void 0 ? options.lintIncludePaths : computeJsxIncludePaths(includePaths);
|
|
195
|
+
const { runLint, runDeadCode } = deps.createRunners({
|
|
196
|
+
resolvedDirectory,
|
|
197
|
+
projectInfo,
|
|
198
|
+
userConfig,
|
|
199
|
+
lintIncludePaths,
|
|
200
|
+
isDiffMode
|
|
201
|
+
});
|
|
202
|
+
const emptyDiagnostics = [];
|
|
203
|
+
const lintPromise = effectiveLint ? runLint().catch((error) => {
|
|
204
|
+
console.error("Lint failed:", error);
|
|
205
|
+
return emptyDiagnostics;
|
|
206
|
+
}) : Promise.resolve(emptyDiagnostics);
|
|
207
|
+
const deadCodePromise = effectiveDeadCode && !isDiffMode ? runDeadCode().catch((error) => {
|
|
208
|
+
console.error("Dead code analysis failed:", error);
|
|
209
|
+
return emptyDiagnostics;
|
|
210
|
+
}) : Promise.resolve(emptyDiagnostics);
|
|
211
|
+
const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
|
|
212
|
+
const environmentDiagnostics = deps.getExtraDiagnostics?.() ?? [];
|
|
213
|
+
const timed = await buildDiagnoseTimedResult({
|
|
214
|
+
mergedDiagnostics: [
|
|
215
|
+
...lintDiagnostics,
|
|
216
|
+
...deadCodeDiagnostics,
|
|
217
|
+
...environmentDiagnostics
|
|
218
|
+
],
|
|
219
|
+
rootDirectory: resolvedDirectory,
|
|
220
|
+
userConfig,
|
|
221
|
+
readFileLinesSync: deps.readFileLinesSync,
|
|
222
|
+
startTime,
|
|
223
|
+
calculateDiagnosticsScore: deps.calculateDiagnosticsScore
|
|
224
|
+
});
|
|
225
|
+
return buildDiagnoseResult({
|
|
226
|
+
diagnostics: timed.diagnostics,
|
|
227
|
+
score: timed.score,
|
|
228
|
+
project: projectInfo,
|
|
229
|
+
elapsedMilliseconds: timed.elapsedMilliseconds
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/plugin/constants.ts
|
|
235
|
+
const MOTION_LIBRARY_PACKAGES = new Set(["framer-motion", "motion"]);
|
|
236
|
+
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/utils/is-file.ts
|
|
239
|
+
const isFile = (filePath) => {
|
|
240
|
+
try {
|
|
241
|
+
return fs.statSync(filePath).isFile();
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/utils/read-package-json.ts
|
|
249
|
+
const readPackageJson = (packageJsonPath) => {
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error instanceof SyntaxError) return {};
|
|
254
|
+
if (error instanceof Error && "code" in error) {
|
|
255
|
+
const { code } = error;
|
|
256
|
+
if (code === "EISDIR" || code === "EACCES") return {};
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/utils/check-reduced-motion.ts
|
|
264
|
+
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
|
|
265
|
+
const REDUCED_MOTION_FILE_GLOBS = "\"*.ts\" \"*.tsx\" \"*.js\" \"*.jsx\" \"*.css\" \"*.scss\"";
|
|
266
|
+
const MISSING_REDUCED_MOTION_DIAGNOSTIC = {
|
|
267
|
+
filePath: "package.json",
|
|
268
|
+
plugin: "react-doctor",
|
|
269
|
+
rule: "require-reduced-motion",
|
|
270
|
+
severity: "error",
|
|
271
|
+
message: "Project uses a motion library but has no prefers-reduced-motion handling — required for accessibility (WCAG 2.3.3)",
|
|
272
|
+
help: "Add `useReducedMotion()` from your animation library, or a `@media (prefers-reduced-motion: reduce)` CSS query",
|
|
273
|
+
line: 0,
|
|
274
|
+
column: 0,
|
|
275
|
+
category: "Accessibility",
|
|
276
|
+
weight: 2
|
|
277
|
+
};
|
|
278
|
+
const checkReducedMotion = (rootDirectory) => {
|
|
279
|
+
const packageJsonPath = path.join(rootDirectory, "package.json");
|
|
280
|
+
if (!isFile(packageJsonPath)) return [];
|
|
281
|
+
let hasMotionLibrary = false;
|
|
282
|
+
try {
|
|
283
|
+
const packageJson = readPackageJson(packageJsonPath);
|
|
284
|
+
const allDependencies = {
|
|
285
|
+
...packageJson.dependencies,
|
|
286
|
+
...packageJson.devDependencies
|
|
287
|
+
};
|
|
288
|
+
hasMotionLibrary = Object.keys(allDependencies).some((packageName) => MOTION_LIBRARY_PACKAGES.has(packageName));
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
if (!hasMotionLibrary) return [];
|
|
293
|
+
try {
|
|
294
|
+
execSync(`git grep -ql -E "${REDUCED_MOTION_GREP_PATTERN}" -- ${REDUCED_MOTION_FILE_GLOBS}`, {
|
|
295
|
+
cwd: rootDirectory,
|
|
296
|
+
stdio: "pipe"
|
|
297
|
+
});
|
|
298
|
+
return [];
|
|
299
|
+
} catch {
|
|
300
|
+
return [MISSING_REDUCED_MOTION_DIAGNOSTIC];
|
|
301
|
+
}
|
|
328
302
|
};
|
|
329
303
|
|
|
330
304
|
//#endregion
|
|
@@ -728,6 +702,19 @@ const loadConfig = (rootDirectory) => {
|
|
|
728
702
|
return null;
|
|
729
703
|
};
|
|
730
704
|
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/utils/read-file-lines-node.ts
|
|
707
|
+
const createNodeReadFileLinesSync = (rootDirectory) => {
|
|
708
|
+
return (filePath) => {
|
|
709
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
|
|
710
|
+
try {
|
|
711
|
+
return fs.readFileSync(absolutePath, "utf-8").split("\n");
|
|
712
|
+
} catch {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
};
|
|
717
|
+
|
|
731
718
|
//#endregion
|
|
732
719
|
//#region src/utils/resolve-lint-include-paths.ts
|
|
733
720
|
const listSourceFilesViaGit = (rootDirectory) => {
|
|
@@ -771,6 +758,136 @@ const resolveLintIncludePaths = (rootDirectory, userConfig) => {
|
|
|
771
758
|
});
|
|
772
759
|
};
|
|
773
760
|
|
|
761
|
+
//#endregion
|
|
762
|
+
//#region src/core/calculate-score-locally.ts
|
|
763
|
+
const getScoreLabel = (score) => {
|
|
764
|
+
if (score >= SCORE_GOOD_THRESHOLD) return "Great";
|
|
765
|
+
if (score >= SCORE_OK_THRESHOLD) return "Needs work";
|
|
766
|
+
return "Critical";
|
|
767
|
+
};
|
|
768
|
+
const countUniqueRules = (diagnostics) => {
|
|
769
|
+
const errorRules = /* @__PURE__ */ new Set();
|
|
770
|
+
const warningRules = /* @__PURE__ */ new Set();
|
|
771
|
+
for (const diagnostic of diagnostics) {
|
|
772
|
+
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
|
|
773
|
+
if (diagnostic.severity === "error") errorRules.add(ruleKey);
|
|
774
|
+
else warningRules.add(ruleKey);
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
errorRuleCount: errorRules.size,
|
|
778
|
+
warningRuleCount: warningRules.size
|
|
779
|
+
};
|
|
780
|
+
};
|
|
781
|
+
const scoreFromRuleCounts = (errorRuleCount, warningRuleCount) => {
|
|
782
|
+
const penalty = errorRuleCount * ERROR_RULE_PENALTY + warningRuleCount * WARNING_RULE_PENALTY;
|
|
783
|
+
return Math.max(0, Math.round(PERFECT_SCORE - penalty));
|
|
784
|
+
};
|
|
785
|
+
const calculateScoreLocally = (diagnostics) => {
|
|
786
|
+
const { errorRuleCount, warningRuleCount } = countUniqueRules(diagnostics);
|
|
787
|
+
const score = scoreFromRuleCounts(errorRuleCount, warningRuleCount);
|
|
788
|
+
return {
|
|
789
|
+
score,
|
|
790
|
+
label: getScoreLabel(score)
|
|
791
|
+
};
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
//#endregion
|
|
795
|
+
//#region src/core/try-score-from-api.ts
|
|
796
|
+
const parseScoreResult = (value) => {
|
|
797
|
+
if (typeof value !== "object" || value === null) return null;
|
|
798
|
+
if (!("score" in value) || !("label" in value)) return null;
|
|
799
|
+
const scoreValue = Reflect.get(value, "score");
|
|
800
|
+
const labelValue = Reflect.get(value, "label");
|
|
801
|
+
if (typeof scoreValue !== "number" || typeof labelValue !== "string") return null;
|
|
802
|
+
return {
|
|
803
|
+
score: scoreValue,
|
|
804
|
+
label: labelValue
|
|
805
|
+
};
|
|
806
|
+
};
|
|
807
|
+
const tryScoreFromApi = async (diagnostics, fetchImplementation) => {
|
|
808
|
+
const controller = new AbortController();
|
|
809
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
810
|
+
try {
|
|
811
|
+
const response = await fetchImplementation(SCORE_API_URL, {
|
|
812
|
+
method: "POST",
|
|
813
|
+
headers: { "Content-Type": "application/json" },
|
|
814
|
+
body: JSON.stringify({ diagnostics }),
|
|
815
|
+
signal: controller.signal
|
|
816
|
+
});
|
|
817
|
+
if (!response.ok) return null;
|
|
818
|
+
return parseScoreResult(await response.json());
|
|
819
|
+
} catch {
|
|
820
|
+
return null;
|
|
821
|
+
} finally {
|
|
822
|
+
clearTimeout(timeoutId);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region src/utils/proxy-fetch.ts
|
|
828
|
+
const getGlobalProcess = () => {
|
|
829
|
+
const candidate = globalThis.process;
|
|
830
|
+
return candidate?.versions?.node ? candidate : void 0;
|
|
831
|
+
};
|
|
832
|
+
const readEnvProxy = () => {
|
|
833
|
+
const proc = getGlobalProcess();
|
|
834
|
+
if (!proc?.env) return void 0;
|
|
835
|
+
return proc.env.HTTPS_PROXY ?? proc.env.https_proxy ?? proc.env.HTTP_PROXY ?? proc.env.http_proxy;
|
|
836
|
+
};
|
|
837
|
+
let isProxyUrlResolved = false;
|
|
838
|
+
let resolvedProxyUrl;
|
|
839
|
+
const getProxyUrl = () => {
|
|
840
|
+
if (isProxyUrlResolved) return resolvedProxyUrl;
|
|
841
|
+
isProxyUrlResolved = true;
|
|
842
|
+
resolvedProxyUrl = readEnvProxy();
|
|
843
|
+
return resolvedProxyUrl;
|
|
844
|
+
};
|
|
845
|
+
const createProxyDispatcher = async (proxyUrl) => {
|
|
846
|
+
try {
|
|
847
|
+
const { ProxyAgent } = await import("undici");
|
|
848
|
+
return new ProxyAgent(proxyUrl);
|
|
849
|
+
} catch {
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
const proxyFetch = async (url, init) => {
|
|
854
|
+
const controller = new AbortController();
|
|
855
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
856
|
+
try {
|
|
857
|
+
const proxyUrl = getProxyUrl();
|
|
858
|
+
const dispatcher = proxyUrl ? await createProxyDispatcher(proxyUrl) : null;
|
|
859
|
+
return await fetch(url, {
|
|
860
|
+
...init,
|
|
861
|
+
signal: controller.signal,
|
|
862
|
+
...dispatcher ? { dispatcher } : {}
|
|
863
|
+
});
|
|
864
|
+
} finally {
|
|
865
|
+
clearTimeout(timeoutId);
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region src/utils/calculate-score-node.ts
|
|
871
|
+
const calculateScore = async (diagnostics) => {
|
|
872
|
+
const apiScore = await tryScoreFromApi(diagnostics, proxyFetch);
|
|
873
|
+
if (apiScore) return apiScore;
|
|
874
|
+
return calculateScoreLocally(diagnostics);
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/utils/collect-unused-file-paths.ts
|
|
879
|
+
const collectUnusedFilePaths = (filesIssues) => {
|
|
880
|
+
if (filesIssues instanceof Set) return [...filesIssues];
|
|
881
|
+
if (Array.isArray(filesIssues)) return filesIssues.filter((entry) => typeof entry === "string");
|
|
882
|
+
if (!isPlainObject(filesIssues)) return [];
|
|
883
|
+
const unusedFilePaths = [];
|
|
884
|
+
for (const innerValue of Object.values(filesIssues)) {
|
|
885
|
+
if (!isPlainObject(innerValue)) continue;
|
|
886
|
+
for (const issue of Object.values(innerValue)) if (isPlainObject(issue) && typeof issue.filePath === "string") unusedFilePaths.push(issue.filePath);
|
|
887
|
+
}
|
|
888
|
+
return unusedFilePaths;
|
|
889
|
+
};
|
|
890
|
+
|
|
774
891
|
//#endregion
|
|
775
892
|
//#region src/utils/run-knip.ts
|
|
776
893
|
const KNIP_CATEGORY_MAP = {
|
|
@@ -868,8 +985,8 @@ const runKnip = async (rootDirectory) => {
|
|
|
868
985
|
} else knipResult = await runKnipWithOptions(rootDirectory);
|
|
869
986
|
const { issues } = knipResult;
|
|
870
987
|
const diagnostics = [];
|
|
871
|
-
for (const
|
|
872
|
-
filePath: path.relative(rootDirectory,
|
|
988
|
+
for (const unusedFilePath of collectUnusedFilePaths(issues.files)) diagnostics.push({
|
|
989
|
+
filePath: path.relative(rootDirectory, unusedFilePath),
|
|
873
990
|
plugin: "knip",
|
|
874
991
|
rule: "files",
|
|
875
992
|
severity: KNIP_SEVERITY_MAP["files"],
|
|
@@ -1554,35 +1671,26 @@ const filterSourceFiles = (filePaths) => filePaths.filter((filePath) => SOURCE_F
|
|
|
1554
1671
|
//#endregion
|
|
1555
1672
|
//#region src/index.ts
|
|
1556
1673
|
const diagnose = async (directory, options = {}) => {
|
|
1557
|
-
const { includePaths = [] } = options;
|
|
1558
|
-
const isDiffMode = includePaths.length > 0;
|
|
1559
|
-
const startTime = performance.now();
|
|
1560
1674
|
const resolvedDirectory = path.resolve(directory);
|
|
1561
|
-
const projectInfo = discoverProject(resolvedDirectory);
|
|
1562
1675
|
const userConfig = loadConfig(resolvedDirectory);
|
|
1563
|
-
const
|
|
1564
|
-
const
|
|
1565
|
-
if (!projectInfo.reactVersion) throw new Error("No React dependency found in package.json");
|
|
1676
|
+
const includePaths = options.includePaths ?? [];
|
|
1677
|
+
const isDiffMode = includePaths.length > 0;
|
|
1566
1678
|
const lintIncludePaths = computeJsxIncludePaths(includePaths) ?? resolveLintIncludePaths(resolvedDirectory, userConfig);
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
score: await calculateScore(diagnostics),
|
|
1583
|
-
project: projectInfo,
|
|
1584
|
-
elapsedMilliseconds
|
|
1585
|
-
};
|
|
1679
|
+
return diagnoseCore({
|
|
1680
|
+
rootDirectory: resolvedDirectory,
|
|
1681
|
+
readFileLinesSync: createNodeReadFileLinesSync(resolvedDirectory),
|
|
1682
|
+
loadUserConfig: () => userConfig,
|
|
1683
|
+
discoverProjectInfo: () => discoverProject(resolvedDirectory),
|
|
1684
|
+
calculateDiagnosticsScore: calculateScore,
|
|
1685
|
+
getExtraDiagnostics: () => isDiffMode ? [] : checkReducedMotion(resolvedDirectory),
|
|
1686
|
+
createRunners: ({ resolvedDirectory: projectRoot, projectInfo, userConfig: config }) => ({
|
|
1687
|
+
runLint: () => runOxlint(projectRoot, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, void 0, config?.customRulesOnly ?? false),
|
|
1688
|
+
runDeadCode: () => runKnip(projectRoot)
|
|
1689
|
+
})
|
|
1690
|
+
}, {
|
|
1691
|
+
...options,
|
|
1692
|
+
lintIncludePaths
|
|
1693
|
+
});
|
|
1586
1694
|
};
|
|
1587
1695
|
|
|
1588
1696
|
//#endregion
|