Skip to content

Commit c405f4a

Browse files
aidenybaiclaude
andcommitted
fix: resolve multiple GitHub issues (#71, #72, #76, #77, #83, #84, #86, #87, #89, #92, #93, #94)
- Bun workspace catalog: version resolution (#87) - Leading / in glob ignore patterns (#86) - Inline suppression comments (#72) - Offline score calculation fallback (#89) - Suppress share link when offline (#92) - Actionable oxlint crash messages (#84, #77) - EISDIR/EACCES crash in readPackageJson (#71) - Custom React Native text components (#93) - Misleading redirect rule message (#83) - @expo/vector-icons deprecation update (#76) - MotionConfig/reducedMotion detection (#94) - Extract shared isPlainObject util, remove unnecessary casts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a8604b9 commit c405f4a

15 files changed

+158
-29
lines changed

packages/react-doctor/src/constants.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000;
3737
export const OFFLINE_MESSAGE =
3838
"You are offline, could not calculate score. Reconnect to calculate.";
3939

40-
export const OFFLINE_FLAG_MESSAGE = "Score not calculated. Remove --offline to calculate score.";
41-
4240
export const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
4341

4442
export const ERROR_RULE_PENALTY = 1.5;

packages/react-doctor/src/plugin/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ export const DEPRECATED_RN_MODULE_REPLACEMENTS: Record<string, string> = {
286286
export const LEGACY_EXPO_PACKAGE_REPLACEMENTS: Record<string, string> = {
287287
"expo-av": "expo-audio for audio and expo-video for video",
288288
"expo-permissions": "the permissions API in each module (e.g. Camera.requestPermissionsAsync())",
289-
"@expo/vector-icons": "expo-image with sf: source URIs",
289+
"@expo/vector-icons":
290+
"expo-symbols or expo-image (see https://docs.expo.dev/versions/latest/sdk/symbols/)",
290291
};
291292

292293
export const REACT_NATIVE_LIST_COMPONENTS = new Set([

packages/react-doctor/src/plugin/rules/nextjs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ export const nextjsNoClientSideRedirect: Rule = {
213213
context.report({
214214
node: child,
215215
message:
216-
"Client-side redirect in useEffect — use redirect() in a server component or middleware instead",
216+
"Client-side redirect in useEffect — use redirect() from next/navigation or handle in middleware instead",
217217
});
218218
}
219219
});

packages/react-doctor/src/plugin/rules/react-native.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ export const rnNoRawText: Rule = {
6565
if (isDomComponentFile) return;
6666

6767
const elementName = resolveJsxElementName(node.openingElement);
68-
if (elementName && REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return;
68+
if (
69+
elementName &&
70+
(REACT_NATIVE_TEXT_COMPONENTS.has(elementName) || elementName.endsWith("Text"))
71+
)
72+
return;
6973

7074
for (const child of node.children ?? []) {
7175
if (!isRawTextContent(child)) continue;

packages/react-doctor/src/scan.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { join } from "node:path";
55
import { performance } from "node:perf_hooks";
66
import {
77
MILLISECONDS_PER_SECOND,
8-
OFFLINE_FLAG_MESSAGE,
98
OFFLINE_MESSAGE,
109
OXLINT_NODE_REQUIREMENT,
1110
OXLINT_RECOMMENDED_NODE_MAJOR,
@@ -23,7 +22,7 @@ import type {
2322
ScanResult,
2423
ScoreResult,
2524
} from "./types.js";
26-
import { calculateScore } from "./utils/calculate-score.js";
25+
import { calculateScore, calculateScoreLocally } from "./utils/calculate-score.js";
2726
import { colorizeByScore } from "./utils/colorize-by-score.js";
2827
import { combineDiagnostics, computeJsxIncludePaths } from "./utils/combine-diagnostics.js";
2928
import { discoverProject, formatFrameworkName } from "./utils/discover-project.js";
@@ -319,6 +318,7 @@ const printSummary = (
319318
projectName: string,
320319
totalSourceFileCount: number,
321320
noScoreMessage: string,
321+
isOffline: boolean,
322322
): void => {
323323
const summaryFramedLines = [
324324
...buildBrandingLines(scoreResult, noScoreMessage),
@@ -334,9 +334,11 @@ const printSummary = (
334334
logger.break();
335335
}
336336

337-
const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
338-
logger.break();
339-
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
337+
if (!isOffline) {
338+
const shareUrl = buildShareUrl(diagnostics, scoreResult, projectName);
339+
logger.break();
340+
logger.dim(` Share your results: ${highlighter.info(shareUrl)}`);
341+
}
340342
};
341343

342344
const resolveOxlintNode = async (
@@ -554,8 +556,10 @@ export const scan = async (
554556
if (didDeadCodeFail) skippedChecks.push("dead code");
555557
const hasSkippedChecks = skippedChecks.length > 0;
556558

557-
const scoreResult = options.offline ? null : await calculateScore(diagnostics);
558-
const noScoreMessage = options.offline ? OFFLINE_FLAG_MESSAGE : OFFLINE_MESSAGE;
559+
const scoreResult = options.offline
560+
? calculateScoreLocally(diagnostics)
561+
: await calculateScore(diagnostics);
562+
const noScoreMessage = OFFLINE_MESSAGE;
559563

560564
if (options.scoreOnly) {
561565
if (scoreResult) {
@@ -599,6 +603,7 @@ export const scan = async (
599603
projectInfo.projectName,
600604
displayedSourceFileCount,
601605
noScoreMessage,
606+
options.offline,
602607
);
603608

604609
if (hasSkippedChecks) {

packages/react-doctor/src/utils/calculate-score.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ const estimateScoreLocally = (diagnostics: Diagnostic[]): EstimatedScoreResult =
6464
};
6565
};
6666

67+
export const calculateScoreLocally = (diagnostics: Diagnostic[]): ScoreResult => {
68+
const { currentScore, currentLabel } = estimateScoreLocally(diagnostics);
69+
return { score: currentScore, label: currentLabel };
70+
};
71+
6772
export const calculateScore = async (diagnostics: Diagnostic[]): Promise<ScoreResult | null> => {
6873
try {
6974
const response = await proxyFetch(SCORE_API_URL, {
@@ -72,11 +77,11 @@ export const calculateScore = async (diagnostics: Diagnostic[]): Promise<ScoreRe
7277
body: JSON.stringify({ diagnostics }),
7378
});
7479

75-
if (!response.ok) return null;
80+
if (!response.ok) return calculateScoreLocally(diagnostics);
7681

7782
return (await response.json()) as ScoreResult;
7883
} catch {
79-
return null;
84+
return calculateScoreLocally(diagnostics);
8085
}
8186
};
8287

packages/react-doctor/src/utils/check-reduced-motion.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import type { Diagnostic } from "../types.js";
55
import { isFile } from "./is-file.js";
66
import { readPackageJson } from "./read-package-json.js";
77

8-
const REDUCED_MOTION_GREP_PATTERN = "prefers-reduced-motion|useReducedMotion";
8+
const REDUCED_MOTION_GREP_PATTERN =
9+
"prefers-reduced-motion|useReducedMotion|MotionConfig|reducedMotion";
910
const REDUCED_MOTION_FILE_GLOBS = '"*.ts" "*.tsx" "*.js" "*.jsx" "*.css" "*.scss"';
1011

1112
const MISSING_REDUCED_MOTION_DIAGNOSTIC: Diagnostic = {

packages/react-doctor/src/utils/combine-diagnostics.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { JSX_FILE_PATTERN } from "../constants.js";
22
import type { Diagnostic, ReactDoctorConfig } from "../types.js";
33
import { checkReducedMotion } from "./check-reduced-motion.js";
4-
import { filterIgnoredDiagnostics } from "./filter-diagnostics.js";
4+
import { filterIgnoredDiagnostics, filterInlineSuppressions } from "./filter-diagnostics.js";
55

66
export const computeJsxIncludePaths = (includePaths: string[]): string[] | undefined =>
77
includePaths.length > 0
@@ -15,10 +15,11 @@ export const combineDiagnostics = (
1515
isDiffMode: boolean,
1616
userConfig: ReactDoctorConfig | null,
1717
): Diagnostic[] => {
18-
const allDiagnostics = [
18+
const merged = [
1919
...lintDiagnostics,
2020
...deadCodeDiagnostics,
2121
...(isDiffMode ? [] : checkReducedMotion(directory)),
2222
];
23-
return userConfig ? filterIgnoredDiagnostics(allDiagnostics, userConfig) : allDiagnostics;
23+
const filtered = userConfig ? filterIgnoredDiagnostics(merged, userConfig) : merged;
24+
return filterInlineSuppressions(filtered, directory);
2425
};

packages/react-doctor/src/utils/discover-project.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
} from "../types.js";
1212
import { findMonorepoRoot, isMonorepoRoot } from "./find-monorepo-root.js";
1313
import { isFile } from "./is-file.js";
14+
import { isPlainObject } from "./is-plain-object.js";
1415
import { readPackageJson } from "./read-package-json.js";
1516

1617
const REACT_COMPILER_PACKAGES = new Set([
@@ -130,10 +131,43 @@ const detectFramework = (dependencies: Record<string, string>): Framework => {
130131
return "unknown";
131132
};
132133

134+
const isCatalogReference = (version: string): boolean => version.startsWith("catalog:");
135+
136+
const resolveVersionFromCatalog = (
137+
catalog: Record<string, unknown>,
138+
packageName: string,
139+
): string | null => {
140+
const version = catalog[packageName];
141+
if (typeof version === "string" && !isCatalogReference(version)) return version;
142+
return null;
143+
};
144+
145+
const resolveCatalogVersion = (packageJson: PackageJson, packageName: string): string | null => {
146+
const raw = packageJson as Record<string, unknown>;
147+
148+
if (isPlainObject(raw.catalog)) {
149+
const version = resolveVersionFromCatalog(raw.catalog, packageName);
150+
if (version) return version;
151+
}
152+
153+
if (isPlainObject(raw.catalogs)) {
154+
for (const catalogEntries of Object.values(raw.catalogs)) {
155+
if (isPlainObject(catalogEntries)) {
156+
const version = resolveVersionFromCatalog(catalogEntries, packageName);
157+
if (version) return version;
158+
}
159+
}
160+
}
161+
162+
return null;
163+
};
164+
133165
const extractDependencyInfo = (packageJson: PackageJson): DependencyInfo => {
134166
const allDependencies = collectAllDependencies(packageJson);
167+
const rawVersion = allDependencies.react ?? null;
168+
const reactVersion = rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null;
135169
return {
136-
reactVersion: allDependencies.react ?? null,
170+
reactVersion,
137171
framework: detectFramework(allDependencies),
138172
};
139173
};
@@ -216,10 +250,11 @@ const findDependencyInfoFromMonorepoRoot = (directory: string): DependencyInfo =
216250

217251
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
218252
const rootInfo = extractDependencyInfo(rootPackageJson);
253+
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react");
219254
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
220255

221256
return {
222-
reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion,
257+
reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion,
223258
framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework,
224259
};
225260
};
@@ -368,6 +403,10 @@ export const discoverProject = (directory: string): ProjectInfo => {
368403
const packageJson = readPackageJson(packageJsonPath);
369404
let { reactVersion, framework } = extractDependencyInfo(packageJson);
370405

406+
if (!reactVersion) {
407+
reactVersion = resolveCatalogVersion(packageJson, "react");
408+
}
409+
371410
if (!reactVersion || framework === "unknown") {
372411
const workspaceInfo = findReactInWorkspaces(directory, packageJson);
373412
if (!reactVersion && workspaceInfo.reactVersion) {

packages/react-doctor/src/utils/filter-diagnostics.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
13
import type { Diagnostic, ReactDoctorConfig } from "../types.js";
24
import { compileGlobPattern } from "./match-glob-pattern.js";
35

@@ -28,3 +30,57 @@ export const filterIgnoredDiagnostics = (
2830
return true;
2931
});
3032
};
33+
34+
const DISABLE_NEXT_LINE_PATTERN = /\/\/\s*react-doctor-disable-next-line\b(?:\s+(.+))?/;
35+
const DISABLE_LINE_PATTERN = /\/\/\s*react-doctor-disable-line\b(?:\s+(.+))?/;
36+
37+
const isRuleSuppressed = (commentRules: string | undefined, ruleId: string): boolean => {
38+
if (!commentRules?.trim()) return true;
39+
return commentRules.split(/[,\s]+/).some((rule) => rule.trim() === ruleId);
40+
};
41+
42+
export const filterInlineSuppressions = (
43+
diagnostics: Diagnostic[],
44+
rootDirectory: string,
45+
): Diagnostic[] => {
46+
const fileLineCache = new Map<string, string[] | null>();
47+
48+
const getFileLines = (filePath: string): string[] | null => {
49+
const cached = fileLineCache.get(filePath);
50+
if (cached !== undefined) return cached;
51+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(rootDirectory, filePath);
52+
try {
53+
const lines = fs.readFileSync(absolutePath, "utf-8").split("\n");
54+
fileLineCache.set(filePath, lines);
55+
return lines;
56+
} catch {
57+
fileLineCache.set(filePath, null);
58+
return null;
59+
}
60+
};
61+
62+
return diagnostics.filter((diagnostic) => {
63+
if (diagnostic.line <= 0) return true;
64+
65+
const lines = getFileLines(diagnostic.filePath);
66+
if (!lines) return true;
67+
68+
const ruleId = `${diagnostic.plugin}/${diagnostic.rule}`;
69+
70+
const currentLine = lines[diagnostic.line - 1];
71+
if (currentLine) {
72+
const lineMatch = currentLine.match(DISABLE_LINE_PATTERN);
73+
if (lineMatch && isRuleSuppressed(lineMatch[1], ruleId)) return false;
74+
}
75+
76+
if (diagnostic.line >= 2) {
77+
const prevLine = lines[diagnostic.line - 2];
78+
if (prevLine) {
79+
const nextLineMatch = prevLine.match(DISABLE_NEXT_LINE_PATTERN);
80+
if (nextLineMatch && isRuleSuppressed(nextLineMatch[1], ruleId)) return false;
81+
}
82+
}
83+
84+
return true;
85+
});
86+
};

0 commit comments

Comments
 (0)