Skip to content

Commit 1aef45a

Browse files
fix: resolve 7 GitHub issues (catalog resolution, file ignoring, CLI, RN, Next.js, offline, monorepo) (#108)
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 720f421 commit 1aef45a

File tree

21 files changed

+403
-108
lines changed

21 files changed

+403
-108
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"picocolors": "^1.1.1"
2828
},
2929
"devDependencies": {
30-
"@changesets/cli": "^2.27.0",
30+
"@changesets/cli": "^2.30.0",
3131
"eslint-plugin-react-hooks": "^7.0.1",
3232
"oxfmt": "^0.32.0",
3333
"oxlint": "^1.47.0",

packages/react-doctor/src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ const resolveDiffMode = async (
112112

113113
const changedSourceFiles = filterSourceFiles(diffInfo.changedFiles);
114114
if (changedSourceFiles.length === 0) return false;
115-
if (shouldSkipPrompts) return true;
115+
if (shouldSkipPrompts) return false;
116116
if (isScoreOnly) return false;
117117

118118
const promptMessage = diffInfo.isCurrentChanges

packages/react-doctor/src/constants.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ export const GIT_LS_FILES_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
3434
// Use a conservative threshold to leave room for the executable path and quoting overhead.
3535
export const SPAWN_ARGS_MAX_LENGTH_CHARS = 24_000;
3636

37-
export const OFFLINE_MESSAGE =
38-
"You are offline, could not calculate score. Reconnect to calculate.";
37+
export const OFFLINE_MESSAGE = "Score calculated locally (offline mode).";
3938

4039
export const DEFAULT_BRANCH_CANDIDATES = ["main", "master"];
4140

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,15 @@ export const RAW_TEXT_PREVIEW_MAX_CHARS = 30;
264264

265265
export const REACT_NATIVE_TEXT_COMPONENTS = new Set(["Text", "TextInput"]);
266266

267+
export const REACT_NATIVE_TEXT_COMPONENT_SUFFIXES = new Set([
268+
"Text",
269+
"Title",
270+
"Label",
271+
"Heading",
272+
"Caption",
273+
"Subtitle",
274+
]);
275+
267276
export const DEPRECATED_RN_MODULE_REPLACEMENTS: Record<string, string> = {
268277
AsyncStorage: "@react-native-async-storage/async-storage",
269278
Picker: "@react-native-picker/picker",

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

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,24 +181,28 @@ export const nextjsMissingMetadata: Rule = {
181181
}),
182182
};
183183

184-
const isClientSideRedirect = (node: EsTreeNode): boolean => {
184+
const describeClientSideNavigation = (node: EsTreeNode): string | null => {
185185
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression") {
186186
const objectName = node.callee.object?.type === "Identifier" ? node.callee.object.name : null;
187-
if (
188-
objectName === "router" &&
189-
(isMemberProperty(node.callee, "push") || isMemberProperty(node.callee, "replace"))
190-
)
191-
return true;
187+
const methodName =
188+
node.callee.property?.type === "Identifier" ? node.callee.property.name : null;
189+
if (objectName === "router" && (methodName === "push" || methodName === "replace")) {
190+
return `router.${methodName}() in useEffect — use redirect() from next/navigation or handle navigation in an event handler`;
191+
}
192192
}
193193

194194
if (node.type === "AssignmentExpression" && node.left?.type === "MemberExpression") {
195195
const objectName = node.left.object?.type === "Identifier" ? node.left.object.name : null;
196196
const propertyName = node.left.property?.type === "Identifier" ? node.left.property.name : null;
197-
if (objectName === "window" && propertyName === "location") return true;
198-
if (objectName === "location" && propertyName === "href") return true;
197+
if (objectName === "window" && propertyName === "location") {
198+
return "window.location assignment in useEffect — use redirect() from next/navigation or handle in middleware instead";
199+
}
200+
if (objectName === "location" && propertyName === "href") {
201+
return "location.href assignment in useEffect — use redirect() from next/navigation or handle in middleware instead";
202+
}
199203
}
200204

201-
return false;
205+
return null;
202206
};
203207

204208
export const nextjsNoClientSideRedirect: Rule = {
@@ -209,11 +213,11 @@ export const nextjsNoClientSideRedirect: Rule = {
209213
if (!callback) return;
210214

211215
walkAst(callback, (child: EsTreeNode) => {
212-
if (isClientSideRedirect(child)) {
216+
const navigationDescription = describeClientSideNavigation(child);
217+
if (navigationDescription) {
213218
context.report({
214219
node: child,
215-
message:
216-
"Client-side redirect in useEffect — use redirect() from next/navigation or handle in middleware instead",
220+
message: navigationDescription,
217221
});
218222
}
219223
});

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
RAW_TEXT_PREVIEW_MAX_CHARS,
66
REACT_NATIVE_LIST_COMPONENTS,
77
REACT_NATIVE_TEXT_COMPONENTS,
8+
REACT_NATIVE_TEXT_COMPONENT_SUFFIXES,
89
} from "../constants.js";
910
import { hasDirective, isMemberProperty } from "../helpers.js";
1011
import type { EsTreeNode, Rule, RuleContext } from "../types.js";
@@ -53,6 +54,11 @@ const getRawTextDescription = (child: EsTreeNode): string => {
5354
return "text content";
5455
};
5556

57+
const isTextHandlingComponent = (elementName: string): boolean => {
58+
if (REACT_NATIVE_TEXT_COMPONENTS.has(elementName)) return true;
59+
return [...REACT_NATIVE_TEXT_COMPONENT_SUFFIXES].some((suffix) => elementName.endsWith(suffix));
60+
};
61+
5662
export const rnNoRawText: Rule = {
5763
create: (context: RuleContext) => {
5864
let isDomComponentFile = false;
@@ -65,11 +71,7 @@ export const rnNoRawText: Rule = {
6571
if (isDomComponentFile) return;
6672

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

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

packages/react-doctor/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export interface PackageJson {
6868
dependencies?: Record<string, string>;
6969
devDependencies?: Record<string, string>;
7070
peerDependencies?: Record<string, string>;
71-
workspaces?: string[] | { packages: string[] };
71+
workspaces?: string[] | { packages?: string[]; catalog?: Record<string, string> };
7272
}
7373

7474
export interface DependencyInfo {

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

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ const detectFramework = (dependencies: Record<string, string>): Framework => {
135135

136136
const isCatalogReference = (version: string): boolean => version.startsWith("catalog:");
137137

138+
const extractCatalogName = (version: string): string | null => {
139+
if (!isCatalogReference(version)) return null;
140+
const name = version.slice("catalog:".length).trim();
141+
return name.length > 0 ? name : null;
142+
};
143+
138144
const resolveVersionFromCatalog = (
139145
catalog: Record<string, unknown>,
140146
packageName: string,
@@ -144,7 +150,112 @@ const resolveVersionFromCatalog = (
144150
return null;
145151
};
146152

147-
const resolveCatalogVersion = (packageJson: PackageJson, packageName: string): string | null => {
153+
interface CatalogCollection {
154+
defaultCatalog: Record<string, string>;
155+
namedCatalogs: Record<string, Record<string, string>>;
156+
}
157+
158+
const parsePnpmWorkspaceCatalogs = (rootDirectory: string): CatalogCollection => {
159+
const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml");
160+
if (!isFile(workspacePath)) return { defaultCatalog: {}, namedCatalogs: {} };
161+
162+
const content = fs.readFileSync(workspacePath, "utf-8");
163+
const defaultCatalog: Record<string, string> = {};
164+
const namedCatalogs: Record<string, Record<string, string>> = {};
165+
166+
let currentSection: "none" | "catalog" | "catalogs" | "named-catalog" = "none";
167+
let currentCatalogName = "";
168+
169+
for (const line of content.split("\n")) {
170+
const trimmed = line.trim();
171+
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
172+
173+
const indentLevel = line.search(/\S/);
174+
175+
if (indentLevel === 0 && trimmed === "catalog:") {
176+
currentSection = "catalog";
177+
continue;
178+
}
179+
if (indentLevel === 0 && trimmed === "catalogs:") {
180+
currentSection = "catalogs";
181+
continue;
182+
}
183+
if (indentLevel === 0) {
184+
currentSection = "none";
185+
continue;
186+
}
187+
188+
if (currentSection === "catalog" && indentLevel > 0) {
189+
const colonIndex = trimmed.indexOf(":");
190+
if (colonIndex > 0) {
191+
const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
192+
const value = trimmed
193+
.slice(colonIndex + 1)
194+
.trim()
195+
.replace(/["']/g, "");
196+
if (key && value) defaultCatalog[key] = value;
197+
}
198+
continue;
199+
}
200+
201+
if (currentSection === "catalogs" && indentLevel > 0) {
202+
if (trimmed.endsWith(":") && !trimmed.includes(" ")) {
203+
currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
204+
currentSection = "named-catalog";
205+
namedCatalogs[currentCatalogName] = {};
206+
continue;
207+
}
208+
}
209+
210+
if (currentSection === "named-catalog" && indentLevel > 0) {
211+
if (indentLevel <= 2 && trimmed.endsWith(":") && !trimmed.includes(" ")) {
212+
currentCatalogName = trimmed.slice(0, -1).replace(/["']/g, "");
213+
namedCatalogs[currentCatalogName] = {};
214+
continue;
215+
}
216+
const colonIndex = trimmed.indexOf(":");
217+
if (colonIndex > 0 && currentCatalogName) {
218+
const key = trimmed.slice(0, colonIndex).trim().replace(/["']/g, "");
219+
const value = trimmed
220+
.slice(colonIndex + 1)
221+
.trim()
222+
.replace(/["']/g, "");
223+
if (key && value) namedCatalogs[currentCatalogName][key] = value;
224+
}
225+
}
226+
}
227+
228+
return { defaultCatalog, namedCatalogs };
229+
};
230+
231+
const resolveCatalogVersionFromCollection = (
232+
catalogs: CatalogCollection,
233+
packageName: string,
234+
catalogReference?: string | null,
235+
): string | null => {
236+
if (catalogReference) {
237+
const namedCatalog = catalogs.namedCatalogs[catalogReference];
238+
if (namedCatalog?.[packageName]) return namedCatalog[packageName];
239+
}
240+
241+
if (catalogs.defaultCatalog[packageName]) return catalogs.defaultCatalog[packageName];
242+
243+
for (const namedCatalog of Object.values(catalogs.namedCatalogs)) {
244+
if (namedCatalog[packageName]) return namedCatalog[packageName];
245+
}
246+
247+
return null;
248+
};
249+
250+
const resolveCatalogVersion = (
251+
packageJson: PackageJson,
252+
packageName: string,
253+
rootDirectory?: string,
254+
): string | null => {
255+
const allDependencies = collectAllDependencies(packageJson);
256+
const rawVersion = allDependencies[packageName];
257+
const catalogName = rawVersion ? extractCatalogName(rawVersion) : null;
258+
148259
const raw = packageJson as Record<string, unknown>;
149260

150261
if (isPlainObject(raw.catalog)) {
@@ -153,6 +264,13 @@ const resolveCatalogVersion = (packageJson: PackageJson, packageName: string): s
153264
}
154265

155266
if (isPlainObject(raw.catalogs)) {
267+
if (catalogName && isPlainObject((raw.catalogs as Record<string, unknown>)[catalogName])) {
268+
const version = resolveVersionFromCatalog(
269+
(raw.catalogs as Record<string, unknown>)[catalogName] as Record<string, unknown>,
270+
packageName,
271+
);
272+
if (version) return version;
273+
}
156274
for (const catalogEntries of Object.values(raw.catalogs)) {
157275
if (isPlainObject(catalogEntries)) {
158276
const version = resolveVersionFromCatalog(catalogEntries, packageName);
@@ -161,6 +279,21 @@ const resolveCatalogVersion = (packageJson: PackageJson, packageName: string): s
161279
}
162280
}
163281

282+
const workspaces = packageJson.workspaces;
283+
if (workspaces && !Array.isArray(workspaces) && isPlainObject(workspaces.catalog)) {
284+
const version = resolveVersionFromCatalog(
285+
workspaces.catalog as Record<string, unknown>,
286+
packageName,
287+
);
288+
if (version) return version;
289+
}
290+
291+
if (rootDirectory) {
292+
const pnpmCatalogs = parsePnpmWorkspaceCatalogs(rootDirectory);
293+
const pnpmVersion = resolveCatalogVersionFromCollection(pnpmCatalogs, packageName, catalogName);
294+
if (pnpmVersion) return pnpmVersion;
295+
}
296+
164297
return null;
165298
};
166299

@@ -252,7 +385,7 @@ const findDependencyInfoFromMonorepoRoot = (directory: string): DependencyInfo =
252385

253386
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
254387
const rootInfo = extractDependencyInfo(rootPackageJson);
255-
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react");
388+
const catalogVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot);
256389
const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson);
257390

258391
return {
@@ -342,6 +475,11 @@ export const listWorkspacePackages = (rootDirectory: string): WorkspacePackage[]
342475

343476
const packages: WorkspacePackage[] = [];
344477

478+
if (hasReactDependency(packageJson)) {
479+
const rootName = packageJson.name ?? path.basename(rootDirectory);
480+
packages.push({ name: rootName, directory: rootDirectory });
481+
}
482+
345483
for (const pattern of patterns) {
346484
const directories = resolveWorkspaceDirectories(rootDirectory, pattern);
347485
for (const workspaceDirectory of directories) {
@@ -406,7 +544,18 @@ export const discoverProject = (directory: string): ProjectInfo => {
406544
let { reactVersion, framework } = extractDependencyInfo(packageJson);
407545

408546
if (!reactVersion) {
409-
reactVersion = resolveCatalogVersion(packageJson, "react");
547+
reactVersion = resolveCatalogVersion(packageJson, "react", directory);
548+
}
549+
550+
if (!reactVersion) {
551+
const monorepoRoot = findMonorepoRoot(directory);
552+
if (monorepoRoot) {
553+
const monorepoPackageJsonPath = path.join(monorepoRoot, "package.json");
554+
if (isFile(monorepoPackageJsonPath)) {
555+
const rootPackageJson = readPackageJson(monorepoPackageJsonPath);
556+
reactVersion = resolveCatalogVersion(rootPackageJson, "react", monorepoRoot);
557+
}
558+
}
410559
}
411560

412561
if (!reactVersion || framework === "unknown") {

packages/react-doctor/tests/discover-project.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,27 @@ describe("discoverProject", () => {
4444

4545
expect(() => discoverProject(projectDirectory)).toThrow("No package.json found");
4646
});
47+
48+
it("resolves React version from pnpm workspace default catalog", () => {
49+
const projectInfo = discoverProject(
50+
path.join(FIXTURES_DIRECTORY, "pnpm-catalog-workspace", "packages", "ui"),
51+
);
52+
expect(projectInfo.reactVersion).toBe("^19.0.0");
53+
});
54+
55+
it("resolves React version from pnpm workspace named catalog", () => {
56+
const projectInfo = discoverProject(
57+
path.join(FIXTURES_DIRECTORY, "pnpm-named-catalog", "packages", "app"),
58+
);
59+
expect(projectInfo.reactVersion).toBe("^19.0.0");
60+
});
61+
62+
it("resolves React version from Bun workspace catalog", () => {
63+
const projectInfo = discoverProject(
64+
path.join(FIXTURES_DIRECTORY, "bun-catalog-workspace", "apps", "web"),
65+
);
66+
expect(projectInfo.reactVersion).toBe("^19.1.4");
67+
});
4768
});
4869

4970
describe("listWorkspacePackages", () => {
@@ -55,6 +76,17 @@ describe("listWorkspacePackages", () => {
5576
expect(packageNames).toContain("ui");
5677
expect(packages).toHaveLength(2);
5778
});
79+
80+
it("includes monorepo root when it has a React dependency", () => {
81+
const packages = listWorkspacePackages(
82+
path.join(FIXTURES_DIRECTORY, "monorepo-with-root-react"),
83+
);
84+
const packageNames = packages.map((workspacePackage) => workspacePackage.name);
85+
86+
expect(packageNames).toContain("monorepo-root");
87+
expect(packageNames).toContain("ui");
88+
expect(packages).toHaveLength(2);
89+
});
5890
});
5991

6092
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-discover-test-"));

0 commit comments

Comments
 (0)