react-doctor 0.0.37 → 0.0.40

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.
@@ -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;;;UClFjB,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"}
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/constants.ts
12
- const SOURCE_FILE_PATTERN = /\.(tsx?|jsx?)$/;
13
- const JSX_FILE_PATTERN = /\.(tsx|jsx)$/;
14
- const ERROR_PREVIEW_LENGTH_CHARS = 200;
15
- const PERFECT_SCORE = 100;
16
- const SCORE_GOOD_THRESHOLD = 75;
17
- const SCORE_OK_THRESHOLD = 50;
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 absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
254
- try {
255
- const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
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/combine-diagnostics.ts
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
- const combineDiagnostics = (lintDiagnostics, deadCodeDiagnostics, directory, isDiffMode, userConfig) => {
322
- const merged = [
323
- ...lintDiagnostics,
324
- ...deadCodeDiagnostics,
325
- ...isDiffMode ? [] : checkReducedMotion(directory)
326
- ];
327
- return filterInlineSuppressions(userConfig ? filterIgnoredDiagnostics(merged, userConfig, directory) : merged, directory);
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 unusedFile of issues.files) diagnostics.push({
872
- filePath: path.relative(rootDirectory, unusedFile),
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 effectiveLint = options.lint ?? userConfig?.lint ?? true;
1564
- const effectiveDeadCode = options.deadCode ?? userConfig?.deadCode ?? true;
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
- const emptyDiagnostics = [];
1568
- const effectiveCustomRulesOnly = userConfig?.customRulesOnly ?? false;
1569
- const lintPromise = effectiveLint ? runOxlint(resolvedDirectory, projectInfo.hasTypeScript, projectInfo.framework, projectInfo.hasReactCompiler, lintIncludePaths, void 0, effectiveCustomRulesOnly).catch((error) => {
1570
- console.error("Lint failed:", error);
1571
- return emptyDiagnostics;
1572
- }) : Promise.resolve(emptyDiagnostics);
1573
- const deadCodePromise = effectiveDeadCode && !isDiffMode ? runKnip(resolvedDirectory).catch((error) => {
1574
- console.error("Dead code analysis failed:", error);
1575
- return emptyDiagnostics;
1576
- }) : Promise.resolve(emptyDiagnostics);
1577
- const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]);
1578
- const diagnostics = combineDiagnostics(lintDiagnostics, deadCodeDiagnostics, resolvedDirectory, isDiffMode, userConfig);
1579
- const elapsedMilliseconds = performance.now() - startTime;
1580
- return {
1581
- diagnostics,
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