From 89989d5155dd4f4e2a9c123bb4d3a523c119ac25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 16:17:51 +0000 Subject: [PATCH 1/4] refactor: simplify architecture cleanup helpers Co-authored-by: Aiden Bai --- .../core/src/project-info/dependencies.ts | 14 ++------ .../core/src/project-info/discover-project.ts | 3 +- .../discover-react-subprojects.ts | 8 ++--- .../get-preferred-dependency-version.ts | 8 ++--- .../core/src/project-info/monorepo-root.ts | 8 ++--- packages/core/src/services/files.ts | 36 ++++++++----------- packages/core/tests/services/files.test.ts | 3 +- .../eslint-plugin-react-doctor/src/index.ts | 8 ++--- .../tests/plugin-shape.test.ts | 6 ++-- .../src/cli/ink/components/report.tsx | 10 ++---- scripts/fn-mining/run-fn-mining.ts | 3 ++ 11 files changed, 40 insertions(+), 67 deletions(-) diff --git a/packages/core/src/project-info/dependencies.ts b/packages/core/src/project-info/dependencies.ts index d75d226650..feefbaf50d 100644 --- a/packages/core/src/project-info/dependencies.ts +++ b/packages/core/src/project-info/dependencies.ts @@ -266,22 +266,14 @@ export const extractDependencyInfo = (packageJson: PackageJson): DependencyInfo ...packageJson.dependencies, ...packageJson.devDependencies, }; - const reactVersion = pickConcreteVersion(packageJson, "react", [ - "dependencies", - "peerDependencies", - "devDependencies", - ]); + const reactVersion = pickConcreteVersion(packageJson, "react", REACT_SECTIONS); const tailwindVersion = pickConcreteVersion( packageJson, "tailwindcss", - ["dependencies", "devDependencies", "peerDependencies"], + TAILWIND_ZOD_SECTIONS, isTailwindPostcss7CompatAlias, ); - const zodVersion = pickConcreteVersion(packageJson, "zod", [ - "dependencies", - "devDependencies", - "peerDependencies", - ]); + const zodVersion = pickConcreteVersion(packageJson, "zod", TAILWIND_ZOD_SECTIONS); return { reactVersion, tailwindVersion, diff --git a/packages/core/src/project-info/discover-project.ts b/packages/core/src/project-info/discover-project.ts index d9c69511b2..b58a6d03a3 100644 --- a/packages/core/src/project-info/discover-project.ts +++ b/packages/core/src/project-info/discover-project.ts @@ -25,7 +25,7 @@ import { SHOPIFY_FLASH_LIST_PACKAGE_NAME, } from "./collect-project-facts.js"; import { resolveInstalledReactVersion } from "./resolve-installed-react-version.js"; -import { readPackageJson } from "./package-json.js"; +import { clearPackageJsonCache, readPackageJson } from "./package-json.js"; import { getTanStackQueryVersion } from "./get-tanstack-query-version.js"; import { getDependencyMajorWithinSupportedRange, @@ -51,6 +51,7 @@ export interface DiscoverProjectOptions { // tsconfig.json / monorepo manifests change between diagnose() calls. export const clearProjectCache = (): void => { cachedProjectInfos.clear(); + clearPackageJsonCache(); clearTargetBlankOpenerProtectionCache(); }; diff --git a/packages/core/src/project-info/discover-react-subprojects.ts b/packages/core/src/project-info/discover-react-subprojects.ts index 32f78e0435..f0b2e0b933 100644 --- a/packages/core/src/project-info/discover-react-subprojects.ts +++ b/packages/core/src/project-info/discover-react-subprojects.ts @@ -5,9 +5,8 @@ import { isDirectory, isFile, readDirectoryEntries } from "./fs-utils.js"; import { hasReactDependency } from "./dependencies.js"; import { readPackageJson } from "./package-json.js"; import { - getNxWorkspaceDirectories, + getWorkspacePatterns, listWorkspacePackages, - parsePnpmWorkspacePatterns, resolveWorkspaceDirectories, } from "./workspaces.js"; @@ -32,9 +31,8 @@ const listManifestWorkspacePackages = (rootDirectory: string): WorkspacePackage[ const packageJsonPath = path.join(rootDirectory, "package.json"); if (isFile(packageJsonPath)) return listWorkspacePackages(rootDirectory); - const patterns = parsePnpmWorkspacePatterns(rootDirectory); - const nxPatterns = patterns.length > 0 ? [] : getNxWorkspaceDirectories(rootDirectory); - const directories = (patterns.length > 0 ? patterns : nxPatterns).flatMap((pattern) => + const patterns = getWorkspacePatterns(rootDirectory, {}); + const directories = patterns.flatMap((pattern) => resolveWorkspaceDirectories(rootDirectory, pattern), ); diff --git a/packages/core/src/project-info/get-preferred-dependency-version.ts b/packages/core/src/project-info/get-preferred-dependency-version.ts index f3d050b2dd..521c69eb23 100644 --- a/packages/core/src/project-info/get-preferred-dependency-version.ts +++ b/packages/core/src/project-info/get-preferred-dependency-version.ts @@ -1,9 +1,5 @@ import type { PackageJson } from "../types/index.js"; -import { getDependencyDeclaration } from "./dependencies.js"; - -const PREFERRED_DEPENDENCY_SECTIONS: ReadonlyArray< - "dependencies" | "peerDependencies" | "devDependencies" -> = ["dependencies", "peerDependencies", "devDependencies"]; +import { getDependencyDeclaration, REACT_SECTIONS } from "./dependencies.js"; interface GetPreferredDependencyVersionOptions { packageJson: PackageJson; @@ -18,7 +14,7 @@ export const getPreferredDependencyVersion = ({ const declaration = getDependencyDeclaration({ packageJson, packageName, - sections: PREFERRED_DEPENDENCY_SECTIONS, + sections: REACT_SECTIONS, }); if (declaration.version !== null) return declaration.version; } diff --git a/packages/core/src/project-info/monorepo-root.ts b/packages/core/src/project-info/monorepo-root.ts index 2291bdf9ac..aee13ef487 100644 --- a/packages/core/src/project-info/monorepo-root.ts +++ b/packages/core/src/project-info/monorepo-root.ts @@ -2,6 +2,7 @@ // cycle — dependencies.ts imports findMonorepoRoot while workspaces.ts imports // dependencies.ts. import * as path from "node:path"; +import { ancestorDirectories } from "../utils/ancestor-directories.js"; import { isFile } from "./fs-utils.js"; import { readPackageJson } from "./package-json.js"; @@ -15,11 +16,8 @@ export const isMonorepoRoot = (directory: string): boolean => { }; export const findMonorepoRoot = (startDirectory: string): string | null => { - let currentDirectory = path.dirname(startDirectory); - - while (currentDirectory !== path.dirname(currentDirectory)) { - if (isMonorepoRoot(currentDirectory)) return currentDirectory; - currentDirectory = path.dirname(currentDirectory); + for (const ancestorDirectory of ancestorDirectories(startDirectory, { includeStart: false })) { + if (isMonorepoRoot(ancestorDirectory)) return ancestorDirectory; } return null; diff --git a/packages/core/src/services/files.ts b/packages/core/src/services/files.ts index 6abf45381d..23cbc1d22e 100644 --- a/packages/core/src/services/files.ts +++ b/packages/core/src/services/files.ts @@ -53,6 +53,15 @@ export class Files extends Context.Service< static readonly layerInMemory = (tree: ReadonlyMap): Layer.Layer => { const resolveAbsolute = (filePath: string, rootDirectory: string): string => path.isAbsolute(filePath) ? filePath : `${rootDirectory}/${filePath}`; + const listRelativePaths = (rootDirectory: string): string[] => { + const prefix = rootDirectory.endsWith("/") ? rootDirectory : `${rootDirectory}/`; + const files: string[] = []; + for (const absolute of tree.keys()) { + if (!absolute.startsWith(prefix)) continue; + files.push(absolute.slice(prefix.length)); + } + return files; + }; return Layer.succeed( Files, @@ -63,31 +72,14 @@ export class Files extends Context.Service< const content = tree.get(absolute); return content === undefined ? null : content.split("\n"); }), - listSourceFiles: (rootDirectory) => - Effect.sync(() => { - const prefix = rootDirectory.endsWith("/") ? rootDirectory : `${rootDirectory}/`; - const files: string[] = []; - for (const absolute of tree.keys()) { - if (!absolute.startsWith(prefix)) continue; - files.push(absolute.slice(prefix.length)); - } - return files; - }), + listSourceFiles: (rootDirectory) => Effect.sync(() => listRelativePaths(rootDirectory)), listSourceFilesCooperative: (input) => - Effect.sync(() => { - const rootDirectory = input.rootDirectory; - const prefix = rootDirectory.endsWith("/") ? rootDirectory : `${rootDirectory}/`; - const files: string[] = []; - for (const absolute of tree.keys()) { - if (!absolute.startsWith(prefix)) continue; - files.push(absolute.slice(prefix.length)); - } - return files; - }), - isFile: (filePath) => Effect.sync(() => tree.has(filePath)), + Effect.sync(() => listRelativePaths(input.rootDirectory)), + isFile: (filePath) => Effect.sync(() => tree.has(path.resolve(filePath))), isDirectory: (filePath) => Effect.sync(() => { - const prefix = filePath.endsWith("/") ? filePath : `${filePath}/`; + const absolutePath = path.resolve(filePath); + const prefix = absolutePath.endsWith("/") ? absolutePath : `${absolutePath}/`; for (const absolute of tree.keys()) { if (absolute.startsWith(prefix)) return true; } diff --git a/packages/core/tests/services/files.test.ts b/packages/core/tests/services/files.test.ts index 7980f71560..50d3071864 100644 --- a/packages/core/tests/services/files.test.ts +++ b/packages/core/tests/services/files.test.ts @@ -36,7 +36,8 @@ describe("Files.layerInMemory", () => { }), ); expect(lines).not.toBeNull(); - expect((lines as string[]).length).toBeGreaterThan(0); + if (lines === null) throw new Error("Expected relative path read to return lines"); + expect(lines.length).toBeGreaterThan(0); }); it("readLines returns null when the path is absent", async () => { diff --git a/packages/eslint-plugin-react-doctor/src/index.ts b/packages/eslint-plugin-react-doctor/src/index.ts index 5aef38687e..fedf2e49ac 100644 --- a/packages/eslint-plugin-react-doctor/src/index.ts +++ b/packages/eslint-plugin-react-doctor/src/index.ts @@ -18,7 +18,7 @@ interface EslintRuleContext { } interface EslintAdapterRule { - title?: string; + title: string; severity: "error" | "warn"; create: (context: EslintRuleContext) => RuleVisitors; } @@ -67,11 +67,7 @@ const wrapAsEslintRule = (ruleName: string, ruleImpl: EslintAdapterRule): Eslint meta: { type: ruleImpl.severity === "warn" ? "suggestion" : "problem", docs: { - description: - ruleImpl.title ?? - ruleName - .replaceAll("-", " ") - .replace(/\b\w/g, (innerCharacter) => innerCharacter.toUpperCase()), + description: ruleImpl.title, url: `${RULE_DOCS_BASE_URL}/${PLUGIN_NAMESPACE}/${ruleName}`, recommended: recommendedRuleKeys.has(`${PLUGIN_NAMESPACE}/${ruleName}`), }, diff --git a/packages/eslint-plugin-react-doctor/tests/plugin-shape.test.ts b/packages/eslint-plugin-react-doctor/tests/plugin-shape.test.ts index b594cfd260..ddba150e57 100644 --- a/packages/eslint-plugin-react-doctor/tests/plugin-shape.test.ts +++ b/packages/eslint-plugin-react-doctor/tests/plugin-shape.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import oxlintPlugin, { + ALL_REACT_DOCTOR_RULES, NEXTJS_RULES, PREACT_RULES, REACT_NATIVE_RULES, @@ -12,7 +13,7 @@ import eslintPlugin from "../src/index.js"; describe("eslint-plugin-react-doctor", () => { it("exports the expected plugin shape", () => { expect(eslintPlugin.meta.name).toBe("react-doctor"); - expect(Object.keys(eslintPlugin.rules).length).toBeGreaterThan(0); + expect(Object.keys(eslintPlugin.rules).sort()).toEqual(Object.keys(oxlintPlugin.rules).sort()); expect(Object.keys(eslintPlugin.configs).sort()).toEqual([ "all", "next", @@ -37,6 +38,7 @@ describe("eslint-plugin-react-doctor", () => { expect(eslintPlugin.configs["tanstack-start"].rules).toEqual(TANSTACK_START_RULES); expect(eslintPlugin.configs["tanstack-query"].rules).toEqual(TANSTACK_QUERY_RULES); expect(eslintPlugin.configs.preact.rules).toEqual(PREACT_RULES); + expect(eslintPlugin.configs.all.rules).toEqual(ALL_REACT_DOCTOR_RULES); }); it("only references wrapped rule ids from presets", () => { @@ -54,9 +56,7 @@ describe("eslint-plugin-react-doctor", () => { const eslintRule = eslintPlugin.rules[ruleName]; expect(oxlintRule).toBeDefined(); expect(eslintRule).toBeDefined(); - if (!oxlintRule || !eslintRule) return; - expect(eslintRule.meta.docs.description).toBe("Array index used as a key"); expect(eslintRule.meta.docs.description).toBe(oxlintRule.title); expect(eslintRule.meta.docs.url).toBe( "https://react.doctor/docs/rules/react-doctor/no-array-index-as-key", diff --git a/packages/react-doctor/src/cli/ink/components/report.tsx b/packages/react-doctor/src/cli/ink/components/report.tsx index c0d20f7fde..9a01c8ce20 100644 --- a/packages/react-doctor/src/cli/ink/components/report.tsx +++ b/packages/react-doctor/src/cli/ink/components/report.tsx @@ -100,7 +100,7 @@ export const Report = ({ () => new Set(), ); const [isCiSetupQueued, setIsCiSetupQueued] = useState(false); - const [shouldShowIssueStream, setShouldShowIssueStream] = useState(true); + const didDismissIssueStream = useRef(false); const didRecordCompactReport = useRef(false); const didRecordIssueStream = useRef(false); const didRecordStackedReportCap = useRef(false); @@ -161,7 +161,7 @@ export const Report = ({ markViewerRuleRead(index); }; const openReportScreen = (nextScreen: ReportScreen): void => { - setShouldShowIssueStream(false); + didDismissIssueStream.current = true; setCiSetupFeedback(undefined); setActiveReportScreen(nextScreen); }; @@ -172,10 +172,6 @@ export const Report = ({ label: `Review ${pluralize(diagnosticRows.length, "issue")}`, onSelect: () => { recordReportAction("view-issues"); - if (resolvedViewerSelectedRowIndex !== null) { - setViewerSelectedRowIndex(resolvedViewerSelectedRowIndex); - markViewerRuleRead(resolvedViewerSelectedRowIndex); - } openReportScreen("issues"); }, }); @@ -317,7 +313,7 @@ export const Report = ({ return ( <> - {activeReportScreen === "landing" && shouldShowIssueStream ? issueStream : null} + {activeReportScreen === "landing" && !didDismissIssueStream.current ? issueStream : null} {activeScreenContent} ); diff --git a/scripts/fn-mining/run-fn-mining.ts b/scripts/fn-mining/run-fn-mining.ts index 799820be0a..f8407f04ae 100644 --- a/scripts/fn-mining/run-fn-mining.ts +++ b/scripts/fn-mining/run-fn-mining.ts @@ -64,6 +64,9 @@ for (const [ruleId, ruleResults] of resultsByRule) { const isCarveOut = !result.miningCase.shouldFire; const marker = result.didFire ? " [fired] " : isCarveOut ? " [carved] " : " [SILENT] "; console.log(`${marker}${result.miningCase.description}`); + if (isCarveOut && result.miningCase.carveOutReason) { + console.log(` carve-out: ${result.miningCase.carveOutReason}`); + } for (const parseError of result.parseErrors) { console.log(` parse error: ${parseError}`); } From 9886e89819a8a871dd655593d0b0ba3c3087c89a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 16:19:04 +0000 Subject: [PATCH 2/4] fix: preserve eslint rule title typing Co-authored-by: Aiden Bai --- packages/eslint-plugin-react-doctor/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin-react-doctor/src/index.ts b/packages/eslint-plugin-react-doctor/src/index.ts index fedf2e49ac..1d3b062f82 100644 --- a/packages/eslint-plugin-react-doctor/src/index.ts +++ b/packages/eslint-plugin-react-doctor/src/index.ts @@ -18,7 +18,7 @@ interface EslintRuleContext { } interface EslintAdapterRule { - title: string; + title?: string; severity: "error" | "warn"; create: (context: EslintRuleContext) => RuleVisitors; } @@ -67,7 +67,7 @@ const wrapAsEslintRule = (ruleName: string, ruleImpl: EslintAdapterRule): Eslint meta: { type: ruleImpl.severity === "warn" ? "suggestion" : "problem", docs: { - description: ruleImpl.title, + description: ruleImpl.title ?? ruleName, url: `${RULE_DOCS_BASE_URL}/${PLUGIN_NAMESPACE}/${ruleName}`, recommended: recommendedRuleKeys.has(`${PLUGIN_NAMESPACE}/${ruleName}`), }, From 7496201da6171b65b732a3ddaee87e7f4bfd8977 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 16:25:20 +0000 Subject: [PATCH 3/4] fix: preserve initial issue read state Co-authored-by: Aiden Bai --- packages/react-doctor/src/cli/ink/components/report.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-doctor/src/cli/ink/components/report.tsx b/packages/react-doctor/src/cli/ink/components/report.tsx index 9a01c8ce20..f9fffafa8e 100644 --- a/packages/react-doctor/src/cli/ink/components/report.tsx +++ b/packages/react-doctor/src/cli/ink/components/report.tsx @@ -172,6 +172,10 @@ export const Report = ({ label: `Review ${pluralize(diagnosticRows.length, "issue")}`, onSelect: () => { recordReportAction("view-issues"); + if (resolvedViewerSelectedRowIndex !== null) { + setViewerSelectedRowIndex(resolvedViewerSelectedRowIndex); + markViewerRuleRead(resolvedViewerSelectedRowIndex); + } openReportScreen("issues"); }, }); From d6bbd656ac8369686caa59ece3e0a8b7fcdc44e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 16:39:06 +0000 Subject: [PATCH 4/4] fix: keep in-memory paths platform-neutral Co-authored-by: Aiden Bai --- packages/core/src/services/files.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/files.ts b/packages/core/src/services/files.ts index 23cbc1d22e..db98d72546 100644 --- a/packages/core/src/services/files.ts +++ b/packages/core/src/services/files.ts @@ -75,11 +75,10 @@ export class Files extends Context.Service< listSourceFiles: (rootDirectory) => Effect.sync(() => listRelativePaths(rootDirectory)), listSourceFilesCooperative: (input) => Effect.sync(() => listRelativePaths(input.rootDirectory)), - isFile: (filePath) => Effect.sync(() => tree.has(path.resolve(filePath))), + isFile: (filePath) => Effect.sync(() => tree.has(filePath)), isDirectory: (filePath) => Effect.sync(() => { - const absolutePath = path.resolve(filePath); - const prefix = absolutePath.endsWith("/") ? absolutePath : `${absolutePath}/`; + const prefix = filePath.endsWith("/") ? filePath : `${filePath}/`; for (const absolute of tree.keys()) { if (absolute.startsWith(prefix)) return true; }