From a04b933c027f6addf4161ba0df1c11eb8922b879 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 1 Sep 2026 13:16:03 -0700 Subject: [PATCH 1/5] fix: ignore vendored source map env examples (#1739) --- .../fix-artifact-env-leak-source-maps.md | 5 + .../artifact-env-leak--public-token.tsx | 5 + .../src/plugin/constants/security.ts | 2 +- .../artifact-env-leak.regressions.test.ts | 74 ++++++++ .../rules/security-scan/artifact-env-leak.ts | 14 +- ...public-env-secret-name.regressions.test.ts | 16 ++ .../mask-third-party-source-map-sources.ts | 176 ++++++++++++++++++ 7 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-artifact-env-leak-source-maps.md create mode 100644 packages/fuzz/corpus/regressions/public/artifact-env-leak--public-token.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/mask-third-party-source-map-sources.ts diff --git a/.changeset/fix-artifact-env-leak-source-maps.md b/.changeset/fix-artifact-env-leak-source-maps.md new file mode 100644 index 0000000000..94f4f85716 --- /dev/null +++ b/.changeset/fix-artifact-env-leak-source-maps.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +Avoid `artifact-env-leak` false positives from vendored source-map content and intentionally public token names. diff --git a/packages/fuzz/corpus/regressions/public/artifact-env-leak--public-token.tsx b/packages/fuzz/corpus/regressions/public/artifact-env-leak--public-token.tsx new file mode 100644 index 0000000000..b924618918 --- /dev/null +++ b/packages/fuzz/corpus/regressions/public/artifact-env-leak--public-token.tsx @@ -0,0 +1,5 @@ +// rule: artifact-env-leak +// verdict: pass +// weakness: name-heuristic +// source: issue #1738 +export const publicToken = import.meta.env.VITE_STYTCH_PUBLIC_TOKEN; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/security.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/security.ts index 9d4dd17d2b..91917370eb 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/security.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/security.ts @@ -219,7 +219,7 @@ export const FULL_ENV_LEAK_SECRET_NAME_PATTERN = // header names, not credentials. The suffix must directly follow the secret // keyword so `DATABASE_URL` (the URL IS the secret) keeps firing. export const TRUSTED_PUBLIC_SECRET_NAME_PATTERN = - /(?:SENTRY_DSN|PUBLIC_KEY|PUBLISHABLE|ANON_KEY|POSTHOG_(?:PROJECT_)?TOKEN|POSTHOG_KEY|TLDRAW_LICENSE_KEY|CLERK_PUBLISHABLE_KEY|ALGOLIA_SEARCH_KEY|GC_API_KEY|GOOGLE_MAPS_API_KEY|MAPBOX_TOKEN|MIXPANEL_TOKEN|FACEBOOK_CLIENT_TOKEN|(?:NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC)_(?:DISABLE|ENABLE|ALLOW|REQUIRE)_)|(?:TOKEN|SECRET|PASSWORD|PRIVATE)_(?:KIND|TYPE|URL|URI|ENDPOINT|HEADER|NAME)$/i; + /(?:SENTRY_DSN|PUBLIC_KEY|(?:^|_)PUBLIC_TOKEN$|PUBLISHABLE|ANON_KEY|POSTHOG_(?:PROJECT_)?TOKEN|POSTHOG_KEY|TLDRAW_LICENSE_KEY|CLERK_PUBLISHABLE_KEY|ALGOLIA_SEARCH_KEY|GC_API_KEY|GOOGLE_MAPS_API_KEY|MAPBOX_TOKEN|MIXPANEL_TOKEN|FACEBOOK_CLIENT_TOKEN|(?:NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC)_(?:DISABLE|ENABLE|ALLOW|REQUIRE)_)|(?:TOKEN|SECRET|PASSWORD|PRIVATE)_(?:KIND|TYPE|URL|URI|ENDPOINT|HEADER|NAME)$/i; // Public, client-safe keys designed to ship in the browser, each with a // prefix distinct from the same vendor's secret key (RevenueCat `appl_` diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/artifact-env-leak.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/artifact-env-leak.regressions.test.ts index cf3c156a8c..31fe6999d4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/artifact-env-leak.regressions.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/artifact-env-leak.regressions.test.ts @@ -12,6 +12,80 @@ describe("security-scan/artifact-env-leak — regressions", () => { expect(findings).toHaveLength(1); }); + it("stays silent on public tokens inside a browser artifact", () => { + const findings = runScanRule(artifactEnvLeak, { + relativePath: "dist/assets/index-abc123.js", + content: `const config = { token: "VITE_STYTCH_PUBLIC_TOKEN" };`, + isGeneratedBundle: true, + }); + expect(findings).toHaveLength(0); + }); + + it("still flags secret-qualified public token names", () => { + const findings = runScanRule(artifactEnvLeak, { + relativePath: "dist/assets/index-abc123.js", + content: `const config = { token: "VITE_STYTCH_PUBLIC_TOKEN_SECRET" };`, + isGeneratedBundle: true, + }); + expect(findings).toHaveLength(1); + }); + + it("ignores env examples from node_modules sources embedded in a source map", () => { + const findings = runScanRule(artifactEnvLeak, { + relativePath: "dist/assets/index-abc123.js.map", + content: JSON.stringify({ + version: 3, + sources: [ + "../../node_modules/.pnpm/@reatom+core@1001.3.0/node_modules/@reatom/core/dist/index.js", + ], + sourcesContent: [ + `/** Example: const db = dbVar.set(process.env.DATABASE_URL) */\nexport const atom = {};`, + ], + }), + isGeneratedBundle: true, + }); + expect(findings).toHaveLength(0); + }); + + it("ignores third-party sources when sourceRoot points into node_modules", () => { + const findings = runScanRule(artifactEnvLeak, { + relativePath: "dist/assets/index-abc123.js.map", + content: JSON.stringify({ + version: 3, + sourceRoot: "../../node_modules/", + sources: ["@reatom/core/dist/index.js"], + sourcesContent: [`export const databaseUrl = process.env.DATABASE_URL;`], + }), + isGeneratedBundle: true, + }); + expect(findings).toHaveLength(0); + }); + + it("still flags first-party env access embedded in a source map", () => { + const findings = runScanRule(artifactEnvLeak, { + relativePath: "dist/assets/index-abc123.js.map", + content: JSON.stringify({ + version: 3, + sources: ["../../node_modules/@reatom/core/dist/index.js", "../../src/config.ts"], + sourcesContent: [ + `/** Example: process.env.DATABASE_URL */`, + `export const databaseUrl = process.env.DATABASE_URL;`, + ], + }), + isGeneratedBundle: true, + }); + expect(findings).toHaveLength(1); + }); + + it("keeps conservative raw matching for malformed source maps", () => { + const findings = runScanRule(artifactEnvLeak, { + relativePath: "dist/assets/index-abc123.js.map", + content: `{"sources":["../../node_modules/example.js"],"sourcesContent":["process.env.DATABASE_URL"`, + isGeneratedBundle: true, + }); + expect(findings).toHaveLength(1); + }); + it("stays silent on generated API-reference markdown (medusa TypeList shape)", () => { const findings = runScanRule(artifactEnvLeak, { relativePath: "www/apps/resources/references/types/CommonTypes/page.mdx", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/artifact-env-leak.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/artifact-env-leak.ts index b7858cf58e..21e80c8877 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/artifact-env-leak.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/artifact-env-leak.ts @@ -7,6 +7,7 @@ import { defineRule } from "../../utils/define-rule.js"; import { findSuspiciousPublicEnvSecretNamePattern } from "./utils/find-suspicious-public-env-secret-name.js"; import { hasFullEnvLeakShape } from "./utils/has-full-env-leak-shape.js"; import { maskSourceComments } from "./utils/mask-source-comments.js"; +import { maskThirdPartySourceMapSources } from "./utils/mask-third-party-source-map-sources.js"; import { scanArtifactLeak } from "./utils/scan-artifact-leak.js"; const ARTIFACT_ENV_LEAK_MESSAGE = @@ -23,6 +24,9 @@ export const artifactEnvLeak = defineRule({ recommendation: "Treat public env prefixes as publication, not secrecy; keep secret env vars server-only and rebuild after rotating leaked keys.", scan: (file) => { + const artifactContent = maskThirdPartySourceMapSources(file.relativePath, file.content); + const artifactFile = + artifactContent === file.content ? file : { ...file, content: artifactContent }; let isRawCandidateExact = false; const findRawCandidatePattern = (content: string): RegExp | undefined => { const suspiciousPublicNamePattern = findSuspiciousPublicEnvSecretNamePattern(content); @@ -40,7 +44,7 @@ export const artifactEnvLeak = defineRule({ : undefined; }; const rawCandidateFindings = scanArtifactLeak( - file, + artifactFile, findRawCandidatePattern, ARTIFACT_ENV_LEAK_MESSAGE, ); @@ -48,15 +52,15 @@ export const artifactEnvLeak = defineRule({ const rawFindings = isRawCandidateExact ? rawCandidateFindings - : scanArtifactLeak(file, findArtifactEnvLeakPattern, ARTIFACT_ENV_LEAK_MESSAGE); + : scanArtifactLeak(artifactFile, findArtifactEnvLeakPattern, ARTIFACT_ENV_LEAK_MESSAGE); - const executableContent = maskSourceComments(file.relativePath, file.content); + const executableContent = maskSourceComments(artifactFile.relativePath, artifactFile.content); if (executableContent === undefined) return rawCandidateFindings; - if (executableContent === file.content) return rawFindings; + if (executableContent === artifactFile.content) return rawFindings; return scanArtifactLeak( { - ...file, + ...artifactFile, content: executableContent, }, findArtifactEnvLeakPattern, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/public-env-secret-name.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/public-env-secret-name.regressions.test.ts index c0f86532a9..3cd07bc7bc 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/public-env-secret-name.regressions.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/public-env-secret-name.regressions.test.ts @@ -19,6 +19,22 @@ describe("security-scan/public-env-secret-name — regressions", () => { expect(findings).toHaveLength(0); }); + it("stays silent on public tokens", () => { + const findings = runScanRule(publicEnvSecretName, { + relativePath: "src/lib/identity.ts", + content: `const token = import.meta.env.VITE_STYTCH_PUBLIC_TOKEN;\n`, + }); + expect(findings).toHaveLength(0); + }); + + it("still flags secret-qualified public token names", () => { + const findings = runScanRule(publicEnvSecretName, { + relativePath: "src/lib/identity.ts", + content: `const token = import.meta.env.VITE_STYTCH_PUBLIC_TOKEN_SECRET;\n`, + }); + expect(findings).toHaveLength(1); + }); + it("stays silent on snippets under a docs tree", () => { const findings = runScanRule(publicEnvSecretName, { relativePath: "docs/onboarding/feature-flags/react-router.tsx", diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/mask-third-party-source-map-sources.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/mask-third-party-source-map-sources.ts new file mode 100644 index 0000000000..2a253a6824 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/mask-third-party-source-map-sources.ts @@ -0,0 +1,176 @@ +const SOURCE_MAP_FILE_PATTERN = /\.map$/i; +const JSON_WHITESPACE_PATTERN = /\s/; +const LINE_TERMINATOR_PATTERN = /[^\r\n\u2028\u2029]/g; +const JSON_NULL = "null"; + +interface JsonRange { + start: number; + end: number; +} + +const skipJsonWhitespace = (content: string, start: number): number => { + let index = start; + while (index < content.length && JSON_WHITESPACE_PATTERN.test(content[index] ?? "")) index += 1; + return index; +}; + +const findJsonStringEnd = (content: string, start: number): number | undefined => { + if (content[start] !== '"') return undefined; + let index = start + 1; + while (index < content.length) { + if (content[index] === "\\") { + index += 2; + continue; + } + if (content[index] === '"') return index + 1; + index += 1; + } + return undefined; +}; + +const findJsonValueEnd = (content: string, start: number): number | undefined => { + const valueStart = skipJsonWhitespace(content, start); + if (content[valueStart] === '"') return findJsonStringEnd(content, valueStart); + if (content[valueStart] !== "[" && content[valueStart] !== "{") { + let index = valueStart; + while (index < content.length && ![",", "]", "}"].includes(content[index] ?? "")) index += 1; + return index; + } + + const closingTokens: string[] = [content[valueStart] === "[" ? "]" : "}"]; + let index = valueStart + 1; + while (index < content.length && closingTokens.length > 0) { + if (content[index] === '"') { + const stringEnd = findJsonStringEnd(content, index); + if (stringEnd === undefined) return undefined; + index = stringEnd; + continue; + } + if (content[index] === "[") closingTokens.push("]"); + if (content[index] === "{") closingTokens.push("}"); + if (content[index] === closingTokens.at(-1)) closingTokens.pop(); + index += 1; + } + return closingTokens.length === 0 ? index : undefined; +}; + +const findTopLevelPropertyValueStart = ( + content: string, + propertyName: string, +): number | undefined => { + let index = skipJsonWhitespace(content, 0); + if (content[index] !== "{") return undefined; + index = skipJsonWhitespace(content, index + 1); + + let propertyValueStart: number | undefined; + while (index < content.length && content[index] !== "}") { + const keyEnd = findJsonStringEnd(content, index); + if (keyEnd === undefined) return undefined; + const key = JSON.parse(content.slice(index, keyEnd)); + index = skipJsonWhitespace(content, keyEnd); + if (content[index] !== ":") return undefined; + index = skipJsonWhitespace(content, index + 1); + if (key === propertyName) { + if (propertyValueStart !== undefined) return undefined; + propertyValueStart = index; + } + const valueEnd = findJsonValueEnd(content, index); + if (valueEnd === undefined) return undefined; + index = skipJsonWhitespace(content, valueEnd); + if (content[index] === ",") { + index = skipJsonWhitespace(content, index + 1); + continue; + } + if (content[index] !== "}") return undefined; + } + return propertyValueStart; +}; + +const findSourceContentRanges = ( + content: string, + sourcesContentStart: number, + sourcesContent: unknown[], +): JsonRange[] | undefined => { + let index = skipJsonWhitespace(content, sourcesContentStart); + if (content[index] !== "[") return undefined; + index = skipJsonWhitespace(content, index + 1); + + const ranges: JsonRange[] = []; + for (const sourceContent of sourcesContent) { + if (typeof sourceContent === "string") { + const end = findJsonStringEnd(content, index); + if (end === undefined) return undefined; + ranges.push({ start: index, end }); + index = end; + } else if (sourceContent === null && content.startsWith(JSON_NULL, index)) { + ranges.push({ start: index, end: index + JSON_NULL.length }); + index += JSON_NULL.length; + } else { + return undefined; + } + index = skipJsonWhitespace(content, index); + if (content[index] === ",") { + index = skipJsonWhitespace(content, index + 1); + continue; + } + } + return content[index] === "]" ? ranges : undefined; +}; + +const isThirdPartySource = (sourceRoot: string, source: string): boolean => + `${sourceRoot}/${source}`.split(/[\\/]/).includes("node_modules"); + +export const maskThirdPartySourceMapSources = (relativePath: string, content: string): string => { + if (!SOURCE_MAP_FILE_PATTERN.test(relativePath) || !content.includes('"sourcesContent"')) { + return content; + } + + try { + const sourceMap = JSON.parse(content); + if (typeof sourceMap !== "object" || sourceMap === null || Array.isArray(sourceMap)) { + return content; + } + const sources = Reflect.get(sourceMap, "sources"); + const sourcesContent = Reflect.get(sourceMap, "sourcesContent"); + const sourceRootValue = Reflect.get(sourceMap, "sourceRoot"); + if ( + !Array.isArray(sources) || + !sources.every((source) => typeof source === "string") || + !Array.isArray(sourcesContent) || + sources.length !== sourcesContent.length || + (sourceRootValue !== undefined && typeof sourceRootValue !== "string") + ) { + return content; + } + + const sourcesContentStart = findTopLevelPropertyValueStart(content, "sourcesContent"); + if (sourcesContentStart === undefined) return content; + const sourceContentRanges = findSourceContentRanges( + content, + sourcesContentStart, + sourcesContent, + ); + if (sourceContentRanges === undefined) return content; + + const sourceRoot = sourceRootValue ?? ""; + const contentParts: string[] = []; + let previousEnd = 0; + for (const [sourceIndex, source] of sources.entries()) { + if (!isThirdPartySource(sourceRoot, source)) continue; + const sourceContentRange = sourceContentRanges[sourceIndex]; + if (sourceContentRange === undefined) return content; + contentParts.push(content.slice(previousEnd, sourceContentRange.start)); + contentParts.push( + content + .slice(sourceContentRange.start, sourceContentRange.end) + .replace(LINE_TERMINATOR_PATTERN, " "), + ); + previousEnd = sourceContentRange.end; + } + if (previousEnd === 0) return content; + contentParts.push(content.slice(previousEnd)); + return contentParts.join(""); + } catch { + return content; + } +}; From 28d4343e4d90a8d80c0fdb5eac0173bdd8826866 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 1 Sep 2026 14:07:52 -0700 Subject: [PATCH 2/5] fix: recognize indirect effect cleanup (#1742) --- .changeset/upset-facts-hide.md | 5 + ...t-needs-cleanup--callback-ref-observer.tsx | 19 ++ ...ffect-needs-cleanup--observer-for-each.tsx | 15 ++ .../effect-needs-cleanup--stored-disposer.tsx | 20 ++ ...rver-needs-disconnect--stored-disposer.tsx | 20 ++ .../effect-needs-cleanup-issue-1736.test.ts | 241 ++++++++++++++++++ .../state-and-effects/effect-needs-cleanup.ts | 121 ++++++++- .../effect-observer-needs-disconnect.ts | 37 ++- .../does-effect-invoke-stored-disposer.ts | 129 ++++++++++ 9 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 .changeset/upset-facts-hide.md create mode 100644 packages/fuzz/corpus/regressions/effect-needs-cleanup--callback-ref-observer.tsx create mode 100644 packages/fuzz/corpus/regressions/effect-needs-cleanup--observer-for-each.tsx create mode 100644 packages/fuzz/corpus/regressions/effect-needs-cleanup--stored-disposer.tsx create mode 100644 packages/fuzz/corpus/regressions/effect-observer-needs-disconnect--stored-disposer.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup-issue-1736.test.ts create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/does-effect-invoke-stored-disposer.ts diff --git a/.changeset/upset-facts-hide.md b/.changeset/upset-facts-hide.md new file mode 100644 index 0000000000..56b18585b3 --- /dev/null +++ b/.changeset/upset-facts-hide.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +Avoid cleanup false positives for callback refs, observer iteration, and effect-local stored disposers. diff --git a/packages/fuzz/corpus/regressions/effect-needs-cleanup--callback-ref-observer.tsx b/packages/fuzz/corpus/regressions/effect-needs-cleanup--callback-ref-observer.tsx new file mode 100644 index 0000000000..b592d26844 --- /dev/null +++ b/packages/fuzz/corpus/regressions/effect-needs-cleanup--callback-ref-observer.tsx @@ -0,0 +1,19 @@ +// rule: effect-needs-cleanup +// weakness: framework-gating +// source: issue #1736 callback ref false positive +// verdict: pass + +import { useCallback, useRef } from "react"; + +export const CallbackRefObserver = () => { + const observerRef = useRef(null); + const setRef = useCallback((element: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + if (!element) return; + const observer = new ResizeObserver(() => {}); + observer.observe(element); + observerRef.current = observer; + }, []); + return
; +}; diff --git a/packages/fuzz/corpus/regressions/effect-needs-cleanup--observer-for-each.tsx b/packages/fuzz/corpus/regressions/effect-needs-cleanup--observer-for-each.tsx new file mode 100644 index 0000000000..c8d2f5cb6d --- /dev/null +++ b/packages/fuzz/corpus/regressions/effect-needs-cleanup--observer-for-each.tsx @@ -0,0 +1,15 @@ +// rule: effect-needs-cleanup +// weakness: control-flow +// source: issue #1736 observer forEach false positive +// verdict: pass + +import { useEffect } from "react"; + +export const ObserverForEach = ({ elements }: { elements: HTMLElement[] }) => { + useEffect(() => { + const observer = new IntersectionObserver(() => {}); + elements.forEach((element) => observer.observe(element)); + return () => observer.disconnect(); + }, [elements]); + return null; +}; diff --git a/packages/fuzz/corpus/regressions/effect-needs-cleanup--stored-disposer.tsx b/packages/fuzz/corpus/regressions/effect-needs-cleanup--stored-disposer.tsx new file mode 100644 index 0000000000..6e5b977fa5 --- /dev/null +++ b/packages/fuzz/corpus/regressions/effect-needs-cleanup--stored-disposer.tsx @@ -0,0 +1,20 @@ +// rule: effect-needs-cleanup +// weakness: cleanup-provenance +// source: issue #1736 stored disposer false positive +// verdict: pass + +import { useEffect } from "react"; + +export const StoredDisposer = ({ element }: { element: HTMLElement }) => { + useEffect(() => { + let cleanupObserver = () => {}; + const observe = () => { + const observer = new IntersectionObserver(() => {}); + observer.observe(element); + return () => observer.disconnect(); + }; + cleanupObserver = observe(); + return () => cleanupObserver(); + }, [element]); + return null; +}; diff --git a/packages/fuzz/corpus/regressions/effect-observer-needs-disconnect--stored-disposer.tsx b/packages/fuzz/corpus/regressions/effect-observer-needs-disconnect--stored-disposer.tsx new file mode 100644 index 0000000000..1fdc8b1b87 --- /dev/null +++ b/packages/fuzz/corpus/regressions/effect-observer-needs-disconnect--stored-disposer.tsx @@ -0,0 +1,20 @@ +// rule: effect-observer-needs-disconnect +// weakness: cleanup-provenance +// source: issue #1736 stored disposer false positive +// verdict: pass + +import { useEffect } from "react"; + +export const StoredObserverDisposer = ({ element }: { element: HTMLElement }) => { + useEffect(() => { + let cleanupObserver = () => {}; + const observe = () => { + const observer = new IntersectionObserver(() => {}); + observer.observe(element); + return () => observer.disconnect(); + }; + cleanupObserver = observe(); + return () => cleanupObserver(); + }, [element]); + return null; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup-issue-1736.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup-issue-1736.test.ts new file mode 100644 index 0000000000..793b0a061b --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup-issue-1736.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { effectNeedsCleanup } from "./effect-needs-cleanup.js"; +import { effectObserverNeedsDisconnect } from "./effect-observer-needs-disconnect.js"; + +const closureHeldCleanup = ` +import { useEffect, useRef } from "react"; + +export const ClosureHeldCleanup = ({ selector }: { selector: string }) => { + const ref = useRef(null); + + useEffect(() => { + let cleanupObserver = () => {}; + + const observeHero = () => { + const hero = document.querySelector(selector); + if (!hero) return () => {}; + const observer = new IntersectionObserver(() => {}); + observer.observe(hero); + return () => observer.disconnect(); + }; + + cleanupObserver = observeHero(); + return () => cleanupObserver(); + }, [selector]); + + return
; +}; +`; + +const callbackRefObserver = ` +import { useCallback, useRef, useState } from "react"; + +export const CallbackRefObserver = () => { + const observerRef = useRef(null); + const [width, setWidth] = useState(0); + + const setRef = useCallback((element: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + observerRef.current = null; + if (!element) return; + const observer = new ResizeObserver((entries) => + setWidth(entries[0].contentRect.width) + ); + observer.observe(element); + observerRef.current = observer; + }, []); + + return
{width}
; +}; +`; + +const observeInForEach = ` +import { useEffect, useRef } from "react"; + +export const ObserveInForEach = () => { + const rootRef = useRef(null); + + useEffect(() => { + const root = rootRef.current; + if (!root) return; + const cards = root.querySelectorAll("[data-card]"); + const observer = new IntersectionObserver(() => {}); + cards.forEach((card) => observer.observe(card)); + return () => observer.disconnect(); + }, []); + + return
; +}; +`; + +const teardownViaHelper = ` +import { useEffect } from "react"; + +export const TeardownViaHelper = () => { + useEffect(() => { + let animationFrameId: number | null = null; + let resumeTimer: ReturnType | null = null; + + const stopAutoScroll = () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + }; + + const onEvent = () => { + if (resumeTimer) clearTimeout(resumeTimer); + resumeTimer = setTimeout(() => { + animationFrameId = requestAnimationFrame(() => {}); + }, 1500); + }; + + const events = ["scroll", "pointerdown", "keydown"] as const; + for (const eventName of events) { + window.addEventListener(eventName, onEvent, { passive: true }); + } + + return () => { + for (const eventName of events) { + window.removeEventListener(eventName, onEvent); + } + stopAutoScroll(); + if (resumeTimer) clearTimeout(resumeTimer); + }; + }, []); + + return null; +}; +`; + +const unsafeClosureHeldCleanup = ` +import { useEffect } from "react"; + +export const UnsafeClosureHeldCleanup = ({ selector }: { selector: string }) => { + useEffect(() => { + let frame = 0; + let cleanupObserver = () => {}; + const observeHero = () => { + const hero = document.querySelector(selector); + if (!hero) return () => {}; + const observer = new IntersectionObserver(() => {}); + observer.observe(hero); + return () => observer.disconnect(); + }; + const scheduleReobserve = () => { + frame = window.requestAnimationFrame(() => { + cleanupObserver(); + cleanupObserver = observeHero(); + }); + }; + cleanupObserver = observeHero(); + window.addEventListener("resize", scheduleReobserve); + return () => { + window.cancelAnimationFrame(frame); + cleanupObserver(); + window.removeEventListener("resize", scheduleReobserve); + }; + }, [selector]); + return null; +}; +`; + +const unsafeTeardownViaHelper = teardownViaHelper.replace( + " const onEvent = () => {\n if (resumeTimer) clearTimeout(resumeTimer);", + " const onEvent = () => {", +); + +const callbackRefWithoutUnmountRelease = callbackRefObserver.replace( + " observerRef.current?.disconnect();", + " if (element) observerRef.current?.disconnect();", +); + +const callbackRefWithoutOwnershipStorage = callbackRefObserver.replace( + " observerRef.current = observer;", + "", +); + +const forEachWithoutUniversalDisconnect = observeInForEach.replace( + " return () => observer.disconnect();", + " return () => observer.unobserve(cards[0]);", +); + +const storedDisposerWithoutCleanup = closureHeldCleanup.replace( + " return () => cleanupObserver();", + " return () => {};", +); + +const storedDisposerWithCleanupBeforeAssignment = ` +import { useEffect } from "react"; + +export const StoredDisposerWithEarlyCleanup = ({ element, skip }) => { + useEffect(() => { + let cleanupObserver = () => {}; + const observe = () => { + const observer = new IntersectionObserver(() => {}); + observer.observe(element); + return () => observer.disconnect(); + }; + if (skip) return () => cleanupObserver(); + cleanupObserver = observe(); + return () => {}; + }, [element, skip]); + return null; +}; +`; + +const partialUnsubscribe = ` +import { useEffect } from "react"; + +export const PartialUnsubscribe = ({ api }) => { + useEffect(() => { + const onSelect = () => {}; + api.on("reInit", onSelect); + api.on("select", onSelect); + return () => { + api?.off("select", onSelect); + }; + }, [api]); + return null; +}; +`; + +describe("issue 1736", () => { + it.each([ + ["closure-held cleanup", closureHeldCleanup], + ["callback ref observer", callbackRefObserver], + ["observe in forEach", observeInForEach], + ["teardown through helper", teardownViaHelper], + ])("accepts %s", (_name, code) => { + expect(runRule(effectNeedsCleanup, code).diagnostics).toEqual([]); + }); + + it("accepts a closure-held observer cleanup", () => { + expect(runRule(effectObserverNeedsDisconnect, closureHeldCleanup).diagnostics).toEqual([]); + }); + + it("reports a partial unsubscribe", () => { + expect(runRule(effectNeedsCleanup, partialUnsubscribe).diagnostics).toHaveLength(1); + }); + + it.each([ + ["overwritten animation frames", unsafeClosureHeldCleanup], + ["overwritten timeouts", unsafeTeardownViaHelper], + ["a callback ref without unmount release", callbackRefWithoutUnmountRelease], + ["a callback ref without ownership storage", callbackRefWithoutOwnershipStorage], + ["a partial forEach observer release", forEachWithoutUniversalDisconnect], + ["a stored disposer without cleanup", storedDisposerWithoutCleanup], + ["a stored disposer with only an earlier cleanup", storedDisposerWithCleanupBeforeAssignment], + ])("reports %s", (_name, code) => { + expect(runRule(effectNeedsCleanup, code).diagnostics).toHaveLength(1); + }); + + it.each([ + ["a deferred stored disposer", unsafeClosureHeldCleanup], + ["a stored disposer without cleanup", storedDisposerWithoutCleanup], + ["a stored disposer with only an earlier cleanup", storedDisposerWithCleanupBeforeAssignment], + ])("reports %s for the observer rule", (_name, code) => { + expect(runRule(effectObserverNeedsDisconnect, code).diagnostics).toHaveLength(1); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.ts index 9a49d07f0c..6cbdbf6c0d 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.ts @@ -15,6 +15,7 @@ import { } from "../../constants/react.js"; import { INERT_REF_ONE_SHOT_TIMER_MAX_DELAY_MS } from "../../constants/thresholds.js"; import { defineRule } from "../../utils/define-rule.js"; +import { doesEffectInvokeStoredDisposer } from "../../utils/does-effect-invoke-stored-disposer.js"; import { canNodeReachLaterNodeWithinFunction } from "../../utils/can-node-reach-later-node-within-function.js"; import { componentOrHookDisplayNameForFunction } from "../../utils/component-or-hook-display-name.js"; import { resolveImportedExportName } from "../../utils/find-exported-function-body.js"; @@ -5944,6 +5945,68 @@ const oneShotTimerHasUnmountGuard = (usage: SubscribeLikeUsage, context: RuleCon return hasUnmountInvalidation; }; +const hasReturnedObserverDisconnectAfterSynchronousIteration = ( + callback: EsTreeNode, + usage: SubscribeLikeUsage, + context: RuleContext, +): boolean => { + const usageFunction = findEnclosingFunction(usage.node); + if ( + usage.kind !== "subscribe" || + usage.registrationVerbName !== "observe" || + usage.receiverKey === null || + !usageFunction || + !isSynchronousIteratorCallback(usageFunction) || + !isFunctionLike(callback) || + !isNodeOfType(callback.body, "BlockStatement") + ) { + return false; + } + const matchingCleanupReturns: EsTreeNode[] = []; + walkInsideStatementBlocks(callback.body, (child: EsTreeNode) => { + if (!isNodeOfType(child, "ReturnStatement") || !child.argument) return; + const cleanupFunction = resolveRefOwnedCleanupFunction(child.argument, context); + if (!cleanupFunction || !isFunctionLike(cleanupFunction)) return; + const disconnectCalls: EsTreeNode[] = []; + walkAst(cleanupFunction.body, (cleanupChild: EsTreeNode) => { + if (cleanupChild !== cleanupFunction.body && isFunctionLike(cleanupChild)) return false; + const cleanupCall = isNodeOfType(cleanupChild, "ChainExpression") + ? cleanupChild.expression + : cleanupChild; + const cleanupCallee = isNodeOfType(cleanupCall, "CallExpression") + ? stripParenExpression(cleanupCall.callee) + : null; + if ( + isNodeOfType(cleanupCall, "CallExpression") && + isNodeOfType(cleanupCallee, "MemberExpression") && + !cleanupCallee.computed && + isNodeOfType(cleanupCallee.property, "Identifier") && + cleanupCallee.property.name === "disconnect" && + resolveExpressionKey(cleanupCallee.object, context) === usage.receiverKey + ) { + disconnectCalls.push(cleanupChild); + } + }); + if (doNodesCoverEveryPathFromFunctionEntry(cleanupFunction, disconnectCalls, context)) { + matchingCleanupReturns.push(child); + } + }); + return doMatchingNodesCoverEveryPathAfterUsage(usage.node, matchingCleanupReturns, context); +}; + +const hasEffectLocalStoredDisposerCleanup = ( + callback: EsTreeNode, + usage: SubscribeLikeUsage, + context: RuleContext, +): boolean => + doesEffectInvokeStoredDisposer({ + context, + effectCallback: callback, + resourceNode: usage.node, + doesFunctionReleaseResource: (functionNode) => + isFunctionLike(functionNode) && doesCleanupFunctionReleaseUsage(functionNode, usage, context), + }); + const effectHasCleanupForUsage = ( callback: EsTreeNode, usage: SubscribeLikeUsage, @@ -5960,6 +6023,8 @@ const effectHasCleanupForUsage = ( if ( cleanupRegistryReleasesUsage(callback, usage, context) || symmetricForEachListenerCleanupReleasesUsage(callback, usage, context) || + hasReturnedObserverDisconnectAfterSynchronousIteration(callback, usage, context) || + hasEffectLocalStoredDisposerCleanup(callback, usage, context) || oneShotTimerHasUnmountGuard(usage, context) || hasGuaranteedRefOwnedUnmountCleanup(callback, usage, context) ) { @@ -6758,6 +6823,55 @@ const isReactRefListenerReplacementRelease = ( ); }; +const isReactRefObserverReplacementRelease = ( + releaseCall: EsTreeNodeOfType<"CallExpression">, + usage: SubscribeLikeUsage, + context: RuleContext, +): boolean => { + if ( + usage.kind !== "subscribe" || + usage.registrationVerbName !== "observe" || + usage.receiverKey === null || + !isNodeOfType(usage.node, "CallExpression") + ) { + return false; + } + const ownerFunction = findEnclosingFunction(usage.node); + const releaseCallee = stripParenExpression(releaseCall.callee); + if ( + !ownerFunction || + !isFunctionLike(ownerFunction) || + ownerFunction !== findEnclosingFunction(releaseCall) || + !isFunctionUsedAsReactRef(ownerFunction, context) || + !isNodeOfType(releaseCallee, "MemberExpression") || + releaseCallee.computed || + !isNodeOfType(releaseCallee.property, "Identifier") || + releaseCallee.property.name !== "disconnect" + ) { + return false; + } + const nodeParameterKey = resolveExpressionKey(ownerFunction.params[0], context); + const releaseRefSymbol = resolveReactRefCurrentReceiverSymbol(releaseCallee.object, context); + if (!nodeParameterKey || usage.eventKey !== nodeParameterKey || !releaseRefSymbol) return false; + const ownershipAssignments: EsTreeNode[] = []; + walkAst(ownerFunction.body, (child: EsTreeNode) => { + if (child !== ownerFunction.body && isFunctionLike(child)) return false; + if ( + isNodeOfType(child, "AssignmentExpression") && + child.operator === "=" && + resolveReactRefSymbol(stripParenExpression(child.left), context.scopes)?.id === + releaseRefSymbol.id && + resolveExpressionKey(child.right, context) === usage.receiverKey + ) { + ownershipAssignments.push(child); + } + }); + return ( + doNodesCoverEveryPathFromFunctionEntry(ownerFunction, [releaseCall], context) && + doMatchingNodesCoverEveryPathAfterUsage(usage.node, ownershipAssignments, context) + ); +}; + const findDirectExhaustiveForEachCleanupFunction = ( releaseNode: EsTreeNode, requiredCollectionKeys: ReadonlySet, @@ -7153,7 +7267,12 @@ const doesReleaseCallMatchUsage = ( return true; } - if (isReactRefListenerReplacementRelease(callNode, usage, context)) return true; + if ( + isReactRefListenerReplacementRelease(callNode, usage, context) || + isReactRefObserverReplacementRelease(callNode, usage, context) + ) { + return true; + } if (doesSocketOwnerReleaseListenerUsage(releaseReceiverKey, releaseVerbName, usage, context)) { return true; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-observer-needs-disconnect.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-observer-needs-disconnect.ts index b39ba8bd87..fbdea929f4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-observer-needs-disconnect.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-observer-needs-disconnect.ts @@ -6,6 +6,7 @@ import { } from "../../utils/collect-returned-cleanup-functions.js"; import { collectFunctionReturnStatements } from "../../utils/collect-function-return-statements.js"; import { defineRule } from "../../utils/define-rule.js"; +import { doesEffectInvokeStoredDisposer } from "../../utils/does-effect-invoke-stored-disposer.js"; import { doNodesCoverEveryPathAfterNode } from "../../utils/do-nodes-cover-every-path-after-node.js"; import { doNodesCoverEveryPathFromFunctionEntry } from "../../utils/do-nodes-cover-every-path-from-function-entry.js"; import { getEffectCallback } from "../../utils/get-effect-callback.js"; @@ -676,6 +677,39 @@ const doesReturnedCleanupDisconnectCollection = ( ); }; +const doesEffectInvokeStoredObserverDisposer = ( + callback: EsTreeNode, + tracked: TrackedObserver, + context: RuleContext, +): boolean => { + const doesFunctionDisconnectObserver = (functionNode: EsTreeNode): boolean => { + if (!isFunctionLike(functionNode)) return false; + const disconnectCalls: EsTreeNode[] = []; + walkAst(functionNode.body, (child: EsTreeNode) => { + if (child !== functionNode.body && isFunctionLike(child)) return false; + const callNode = isNodeOfType(child, "ChainExpression") ? child.expression : child; + const callee = isNodeOfType(callNode, "CallExpression") + ? stripParenExpression(callNode.callee) + : null; + if ( + isNodeOfType(callNode, "CallExpression") && + isNodeOfType(callee, "MemberExpression") && + getStaticPropertyName(callee) === "disconnect" && + isTrackedObserverReference(callee.object, tracked.bindingIdentifiers) + ) { + disconnectCalls.push(child); + } + }); + return doNodesCoverEveryPathFromFunctionEntry(functionNode, disconnectCalls, context); + }; + return doesEffectInvokeStoredDisposer({ + context, + effectCallback: callback, + resourceNode: tracked.construction, + doesFunctionReleaseResource: doesFunctionDisconnectObserver, + }); +}; + export const effectObserverNeedsDisconnect = defineRule({ id: "effect-observer-needs-disconnect", title: "Observer created in an effect never disconnected", @@ -812,7 +846,8 @@ export const effectObserverNeedsDisconnect = defineRule({ !tracked.didObserve || tracked.didReleaseAll || tracked.didReleaseAllViaCallbackParameter || - didReleaseEveryActiveTarget + didReleaseEveryActiveTarget || + doesEffectInvokeStoredObserverDisposer(callback, tracked, context) ) { continue; } diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/does-effect-invoke-stored-disposer.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/does-effect-invoke-stored-disposer.ts new file mode 100644 index 0000000000..be96d22e33 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/does-effect-invoke-stored-disposer.ts @@ -0,0 +1,129 @@ +import { collectFunctionReturnStatements } from "./collect-function-return-statements.js"; +import { doNodesCoverEveryPathAfterNode } from "./do-nodes-cover-every-path-after-node.js"; +import { doNodesCoverEveryPathFromFunctionEntry } from "./do-nodes-cover-every-path-from-function-entry.js"; +import { findEnclosingFunction } from "./find-enclosing-function.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { getFunctionBindingIdentifier } from "./get-function-binding-name.js"; +import { isFunctionLike } from "./is-function-like.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { resolveExactLocalFunction } from "./resolve-exact-local-function.js"; +import { stripParenExpression } from "./strip-paren-expression.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import type { RuleContext } from "./rule-context.js"; + +interface StoredEffectDisposerOptions { + context: RuleContext; + effectCallback: EsTreeNode; + resourceNode: EsTreeNode; + doesFunctionReleaseResource: (functionNode: EsTreeNode) => boolean; +} + +export const doesEffectInvokeStoredDisposer = ({ + context, + effectCallback, + resourceNode, + doesFunctionReleaseResource, +}: StoredEffectDisposerOptions): boolean => { + const resourceOwner = findEnclosingFunction(resourceNode); + if ( + !resourceOwner || + !isFunctionLike(resourceOwner) || + resourceOwner === effectCallback || + !isNodeOfType(resourceOwner.body, "BlockStatement") + ) { + return false; + } + const disposerReturns = collectFunctionReturnStatements(resourceOwner).filter( + (returnStatement) => { + const returnedValue = returnStatement.argument + ? stripParenExpression(returnStatement.argument) + : null; + return Boolean(returnedValue && doesFunctionReleaseResource(returnedValue)); + }, + ); + if (!doNodesCoverEveryPathAfterNode(resourceNode, disposerReturns, context, resourceNode)) { + return false; + } + const resourceOwnerBinding = getFunctionBindingIdentifier(resourceOwner); + const resourceOwnerSymbol = resourceOwnerBinding + ? context.scopes.symbolFor(resourceOwnerBinding) + : null; + if (!resourceOwnerSymbol || resourceOwnerSymbol.references.length !== 1) return false; + const resourceOwnerReference = resourceOwnerSymbol.references[0]; + if (!resourceOwnerReference) return false; + const resourceOwnerReferenceRoot = findTransparentExpressionRoot( + resourceOwnerReference.identifier, + ); + const resourceOwnerCall = resourceOwnerReferenceRoot.parent; + const resourceOwnerCallRoot = isNodeOfType(resourceOwnerCall, "CallExpression") + ? findTransparentExpressionRoot(resourceOwnerCall) + : null; + const storageAssignment = resourceOwnerCallRoot?.parent; + if ( + !isNodeOfType(resourceOwnerCall, "CallExpression") || + resourceOwnerCall.callee !== resourceOwnerReferenceRoot || + !isNodeOfType(storageAssignment, "AssignmentExpression") || + storageAssignment.operator !== "=" || + storageAssignment.right !== resourceOwnerCallRoot || + !isNodeOfType(storageAssignment.left, "Identifier") || + findEnclosingFunction(storageAssignment) !== effectCallback + ) { + return false; + } + const storageSymbol = context.scopes.symbolFor(storageAssignment.left); + const initializer = storageSymbol?.initializer + ? stripParenExpression(storageSymbol.initializer) + : null; + if ( + !storageSymbol || + (storageSymbol.kind !== "let" && storageSymbol.kind !== "var") || + !isNodeOfType(storageSymbol.declarationNode, "VariableDeclarator") || + findEnclosingFunction(storageSymbol.declarationNode) !== effectCallback || + !initializer || + !isFunctionLike(initializer) || + !isNodeOfType(initializer.body, "BlockStatement") || + initializer.body.body.length !== 0 + ) { + return false; + } + const cleanupCallsByFunction = new Map(); + for (const reference of storageSymbol.references) { + const referenceRoot = findTransparentExpressionRoot(reference.identifier); + const referenceParent = referenceRoot.parent; + if (referenceParent === storageAssignment && storageAssignment.left === referenceRoot) { + continue; + } + if ( + !isNodeOfType(referenceParent, "CallExpression") || + referenceParent.callee !== referenceRoot + ) { + return false; + } + const callOwner = findEnclosingFunction(referenceParent); + if (!callOwner) return false; + const cleanupCalls = cleanupCallsByFunction.get(callOwner) ?? []; + cleanupCalls.push(referenceParent); + cleanupCallsByFunction.set(callOwner, cleanupCalls); + } + const matchingCleanupReturns = collectFunctionReturnStatements(effectCallback).filter( + (returnStatement) => { + const cleanupFunction = returnStatement.argument + ? resolveExactLocalFunction(returnStatement.argument, context.scopes) + : null; + return Boolean( + cleanupFunction && + doNodesCoverEveryPathFromFunctionEntry( + cleanupFunction, + cleanupCallsByFunction.get(cleanupFunction) ?? [], + context, + ), + ); + }, + ); + return doNodesCoverEveryPathAfterNode( + storageAssignment, + matchingCleanupReturns, + context, + storageAssignment, + ); +}; From 6f3dd033d5697b73b7c47eb0d47a92795cedf12b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 1 Sep 2026 21:01:43 -0700 Subject: [PATCH 3/5] fix: normalize diagnostic file URLs (#1743) --- .changeset/tough-rules-juggle.md | 5 ++++ packages/core/src/apply-ignore-overrides.ts | 4 +-- packages/core/src/build-json-report.ts | 10 +++---- packages/core/src/is-ignored-file.ts | 4 +-- .../src/utils/to-normalized-relative-path.ts | 6 +++- packages/core/tests/build-json-report.test.ts | 28 +++++++++++++++++++ .../merge-and-filter-diagnostics.test.ts | 18 ++++++++++++ 7 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 .changeset/tough-rules-juggle.md diff --git a/.changeset/tough-rules-juggle.md b/.changeset/tough-rules-juggle.md new file mode 100644 index 0000000000..b82937acd9 --- /dev/null +++ b/.changeset/tough-rules-juggle.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Normalize Oxlint file URLs before applying ignore patterns and writing report-relative diagnostic paths. diff --git a/packages/core/src/apply-ignore-overrides.ts b/packages/core/src/apply-ignore-overrides.ts index 9fdc9a71dc..2620556dca 100644 --- a/packages/core/src/apply-ignore-overrides.ts +++ b/packages/core/src/apply-ignore-overrides.ts @@ -2,7 +2,7 @@ import type { Diagnostic, ReactDoctorConfig, ReactDoctorIgnoreOverride } from ". import { isPlainObject } from "./project-info/index.js"; import { isSameRuleKey } from "./rule-key-aliases.js"; import { compileGlobPatternsLenient } from "./utils/match-glob-pattern.js"; -import { toRelativePath } from "./utils/to-relative-path.js"; +import { toNormalizedRelativePath } from "./utils/to-normalized-relative-path.js"; import { warnConfigIssue } from "./utils/warn-config-issue.js"; interface CompiledIgnoreOverride { @@ -76,7 +76,7 @@ export const isDiagnosticIgnoredByOverrides = ( overrides: CompiledIgnoreOverride[], ): boolean => { if (overrides.length === 0) return false; - const relativeFilePath = toRelativePath(diagnostic.filePath, rootDirectory); + const relativeFilePath = toNormalizedRelativePath(diagnostic.filePath, rootDirectory); const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`; return overrides.some( diff --git a/packages/core/src/build-json-report.ts b/packages/core/src/build-json-report.ts index 2623a7731b..226f96569b 100644 --- a/packages/core/src/build-json-report.ts +++ b/packages/core/src/build-json-report.ts @@ -1,4 +1,3 @@ -import * as path from "node:path"; import type { Diagnostic, DiffInfo, @@ -16,6 +15,7 @@ import { summarizeDiagnostics } from "./summarize-diagnostics.js"; import { hasReactRuntime } from "./utils/has-react-runtime.js"; import { isScanComplete } from "./utils/is-scan-complete.js"; import { toNormalizedRelativePath } from "./utils/to-normalized-relative-path.js"; +import { resolveCandidateReadPath } from "./utils/resolve-candidate-read-path.js"; interface BuildJsonReportInput { version: string; @@ -72,11 +72,9 @@ const toJsonReportDiagnostic = ( projectRoot: string, reportRoot: string, ): JsonReportDiagnosticV3 => { - const normalizedFilePath = toNormalizedRelativePath(diagnostic.filePath, projectRoot); - const reportRelativeFilePath = toNormalizedRelativePath( - path.resolve(projectRoot, diagnostic.filePath), - reportRoot, - ); + const resolvedFilePath = resolveCandidateReadPath(projectRoot, diagnostic.filePath); + const normalizedFilePath = toNormalizedRelativePath(resolvedFilePath, projectRoot); + const reportRelativeFilePath = toNormalizedRelativePath(resolvedFilePath, reportRoot); const ruleIdentity = getDiagnosticRuleIdentity(diagnostic); return { ...diagnostic, diff --git a/packages/core/src/is-ignored-file.ts b/packages/core/src/is-ignored-file.ts index 346ddc026d..177de5cb15 100644 --- a/packages/core/src/is-ignored-file.ts +++ b/packages/core/src/is-ignored-file.ts @@ -1,6 +1,6 @@ import type { ReactDoctorConfig } from "./types/index.js"; import { compileGlobPatternsLenient } from "./utils/match-glob-pattern.js"; -import { toRelativePath } from "./utils/to-relative-path.js"; +import { toNormalizedRelativePath } from "./utils/to-normalized-relative-path.js"; import { warnConfigIssue } from "./utils/warn-config-issue.js"; export const compileIgnoredFilePatterns = (userConfig: ReactDoctorConfig | null): RegExp[] => { @@ -18,6 +18,6 @@ export const isFileIgnoredByPatterns = ( patterns: RegExp[], ): boolean => { if (patterns.length === 0) return false; - const relativePath = toRelativePath(filePath, rootDirectory); + const relativePath = toNormalizedRelativePath(filePath, rootDirectory); return patterns.some((pattern) => pattern.test(relativePath)); }; diff --git a/packages/core/src/utils/to-normalized-relative-path.ts b/packages/core/src/utils/to-normalized-relative-path.ts index 31a8f59316..01baf9d201 100644 --- a/packages/core/src/utils/to-normalized-relative-path.ts +++ b/packages/core/src/utils/to-normalized-relative-path.ts @@ -1,6 +1,10 @@ import * as path from "node:path"; +import { resolveCandidateReadPath } from "./resolve-candidate-read-path.js"; export const toNormalizedRelativePath = (filePath: string, rootDirectory: string): string => path - .relative(path.resolve(rootDirectory), path.resolve(rootDirectory, filePath)) + .relative( + path.resolve(rootDirectory), + path.resolve(resolveCandidateReadPath(rootDirectory, filePath)), + ) .replaceAll("\\", "/") || "."; diff --git a/packages/core/tests/build-json-report.test.ts b/packages/core/tests/build-json-report.test.ts index 0ff3358dad..fd73a4438c 100644 --- a/packages/core/tests/build-json-report.test.ts +++ b/packages/core/tests/build-json-report.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; import { buildJsonReport } from "@react-doctor/core"; import type { Diagnostic, InspectResult, ProjectInfo } from "@react-doctor/core"; @@ -116,6 +118,32 @@ describe("buildJsonReport", () => { expect(report.diagnostics[0]).not.toHaveProperty("location"); }); + it("normalizes a diagnostic file URL without changing the report schema", () => { + const fileUrl = pathToFileURL(path.join(projectInfo.rootDirectory, "src", "App.tsx")).href; + const report = buildJsonReport({ + version: "1.2.3", + directory: projectInfo.rootDirectory, + mode: "full", + diff: null, + scans: [ + { + directory: projectInfo.rootDirectory, + result: result({ diagnostics: [{ ...errorDiagnostic, filePath: fileUrl }] }), + }, + ], + totalElapsedMilliseconds: 1200, + }); + + expect(report.schemaVersion).toBe(3); + expect(report.diagnostics[0]).toMatchObject({ + filePath: fileUrl, + normalizedFilePath: "src/App.tsx", + id: expect.stringMatching( + /^src\/App\.tsx::12:1::react-doctor\/no-array-index-as-key::[a-f0-9]{64}$/, + ), + }); + }); + it("assigns distinct occurrence identities to same-site findings from one rule", () => { const cleanupMessage = "Your cleanup may read the wrong node since the ref `sidebarRef.current` can change before it runs."; diff --git a/packages/core/tests/merge-and-filter-diagnostics.test.ts b/packages/core/tests/merge-and-filter-diagnostics.test.ts index 90cc5301ce..b99e43c109 100644 --- a/packages/core/tests/merge-and-filter-diagnostics.test.ts +++ b/packages/core/tests/merge-and-filter-diagnostics.test.ts @@ -416,6 +416,24 @@ describe("buildDiagnosticPipeline — summarizeSuppressions", () => { ]); }); + it("matches `ignore.overrides` when oxlint reports a file URL", () => { + const projectDirectory = setupCase("file-url-override", `const value = 1;\n`); + const pipeline = buildPipeline( + { + ignore: { + overrides: [{ files: ["src/app.tsx"], rules: ["react-doctor/no-derived-state-effect"] }], + }, + }, + projectDirectory, + ); + const fileUrl = pathToFileURL(path.join(projectDirectory, "src", "app.tsx")).href; + + expect(pipeline.apply(baseDiagnostic({ filePath: fileUrl }))).toBeNull(); + expect(pipeline.summarizeSuppressions()).toEqual([ + { rule: "react-doctor/no-derived-state-effect", source: "override", count: 1 }, + ]); + }); + it("tallies inline disable comments as `inline`", () => { const projectDir = setupCase( "suppression-summary-inline", From ca30808bd3581ca5a4ea0b85dc405b149baf741a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 1 Sep 2026 21:23:42 -0700 Subject: [PATCH 4/5] feat(cli): accept positional file paths (#1746) --- .changeset/quiet-taxis-smile.md | 5 ++ packages/react-doctor/README.md | 6 ++ .../react-doctor/src/cli/commands/inspect.ts | 43 ++++++----- .../react-doctor/src/cli/commands/scan.ts | 9 ++- packages/react-doctor/src/cli/index.ts | 9 ++- .../utils/resolve-positional-scan-input.ts | 46 ++++++++++++ .../src/cli/utils/validate-mode-flags.ts | 23 ++++++ .../tests/inspect-action-setup-prompt.test.ts | 33 +++++++++ .../resolve-positional-scan-input.test.ts | 74 +++++++++++++++++++ .../tests/run-scan-command.test.ts | 16 ++++ .../tests/validate-mode-flags.test.ts | 26 +++++++ 11 files changed, 268 insertions(+), 22 deletions(-) create mode 100644 .changeset/quiet-taxis-smile.md create mode 100644 packages/react-doctor/src/cli/utils/resolve-positional-scan-input.ts create mode 100644 packages/react-doctor/tests/resolve-positional-scan-input.test.ts diff --git a/.changeset/quiet-taxis-smile.md b/.changeset/quiet-taxis-smile.md new file mode 100644 index 0000000000..006a40234d --- /dev/null +++ b/.changeset/quiet-taxis-smile.md @@ -0,0 +1,5 @@ +--- +"react-doctor": patch +--- + +Accept source file paths as positional CLI arguments. diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index cadb743b63..1abb4d276d 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -25,6 +25,12 @@ Run this at your project root to get an audit. npx react-doctor@latest ``` +Pass file paths to scan only the files that another CI step selected. + +```bash +npx react-doctor@latest src/a.tsx src/b.tsx +``` + https://github.com/user-attachments/assets/07cc88d9-9589-44c3-aa73-5d603cb1c570 ### 2. Install for agents diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 34d8e5cfd0..ac6b621b88 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -78,14 +78,14 @@ import { } from "../utils/finalize-cli-scans.js"; import { runStagedInspect } from "../utils/run-staged-inspect.js"; -const buildChangedFilesDiffInfo = (changedFiles: string[]): DiffInfo => ({ +const buildFileSelectionDiffInfo = (filePaths: ReadonlyArray): DiffInfo => ({ currentBranch: process.env.GITHUB_HEAD_REF?.trim() || null, baseBranch: process.env.GITHUB_BASE_REF?.trim() || "pull request target", // The GitHub Action forwards the PR base commit so baseline mode can read // base content against a SHA that's actually fetched (branch names rarely // resolve in a shallow PR checkout). Empty in non-Action runs. baseSha: process.env.REACT_DOCTOR_BASE_SHA?.trim() || undefined, - changedFiles, + changedFiles: [...filePaths], isCurrentChanges: false, }); @@ -261,6 +261,7 @@ export const inspectAction = async ( directory: string, flags: InspectFlags, invocationCommand = "inspect", + selectedFilePaths?: ReadonlyArray, ): Promise => { const isScoreOnly = Boolean(flags.score); const isJsonMode = Boolean(flags.json); @@ -375,19 +376,22 @@ export const inspectAction = async ( userConfig?.projects, ); const projectSelectionCompletedTime = performance.now(); - let changedFilesDiffInfo = flags.changedFilesFrom - ? buildChangedFilesDiffInfo(readChangedFilesFrom(path.resolve(flags.changedFilesFrom))) - : null; - if (changedFilesDiffInfo !== null && scanTarget.didRedirectViaRootDir) { + const hasPositionalFileSelection = selectedFilePaths !== undefined; + let providedFilesDiffInfo = hasPositionalFileSelection + ? buildFileSelectionDiffInfo(selectedFilePaths) + : flags.changedFilesFrom + ? buildFileSelectionDiffInfo(readChangedFilesFrom(path.resolve(flags.changedFilesFrom))) + : null; + if (providedFilesDiffInfo !== null && requestedDirectory !== resolvedDirectory) { const relativeProjectDirectory = resolveProjectRelativeDirectory( requestedDirectory, resolvedDirectory, ); if (relativeProjectDirectory) { const projectPrefix = `${relativeProjectDirectory}/`; - changedFilesDiffInfo = { - ...changedFilesDiffInfo, - changedFiles: changedFilesDiffInfo.changedFiles.flatMap((filePath) => { + providedFilesDiffInfo = { + ...providedFilesDiffInfo, + changedFiles: providedFilesDiffInfo.changedFiles.flatMap((filePath) => { return filePath.startsWith(projectPrefix) ? [filePath.slice(projectPrefix.length)] : []; }), }; @@ -397,11 +401,11 @@ export const inspectAction = async ( // Untracked files only exist in a local working tree, so this is a // CLI-only modifier (like `--staged`) — off unless the user opts in. const includeUntracked = flags.includeUntracked ?? false; - // The internal `--changed-files-from` path (the GitHub Action) implies the - // `changed` scope when the user didn't pick one explicitly — it always ran - // in diff mode historically. - const scopeRequest: RequestedScope = - requestedScope.scope === undefined && changedFilesDiffInfo !== null + // Positional files are an exact selection. The internal + // `--changed-files-from` path implies the historical `changed` scope. + const scopeRequest: RequestedScope = hasPositionalFileSelection + ? { scope: "files", base: undefined, usedDeprecatedDiff: false } + : requestedScope.scope === undefined && providedFilesDiffInfo !== null ? { ...requestedScope, scope: "changed" } : requestedScope; // Validate against the EFFECTIVE scope (post `--changed-files-from` @@ -423,10 +427,10 @@ export const inspectAction = async ( // "full vs changed" prompt never appears for users on a feature branch who // didn't explicitly pass a scope. const shouldDetectDiff = - changedFilesDiffInfo === null && + providedFilesDiffInfo === null && (wantsDiffMode || (scopeRequest.scope === undefined && !skipPrompts && !isQuiet)); const diffInfo = - changedFilesDiffInfo ?? + providedFilesDiffInfo ?? (shouldDetectDiff ? await getDiffInfo(resolvedDirectory, scopeRequest.base, includeUntracked) : null); @@ -449,7 +453,7 @@ export const inspectAction = async ( // exact base (`diffBaseRef`). `null` when uncommitted, detached, or git is // unavailable. Shared by `changed` (baseline) and `lines` (hunk ranges). const comparisonBaseRef = - isDiffMode && diffInfo && !diffInfo.isCurrentChanges + isDiffMode && diffInfo && !diffInfo.isCurrentChanges && !hasPositionalFileSelection ? diffInfo.baseSha ? await resolveMergeBaseRef(resolvedDirectory, diffInfo.baseSha) : (diffInfo.diffBaseRef ?? @@ -497,7 +501,10 @@ export const inspectAction = async ( setJsonReportMode(baselineRef ? "baseline" : isDiffMode ? "diff" : "full"); if (isDiffMode && diffInfo && !isQuiet) { - if (diffInfo.isCurrentChanges) { + if (hasPositionalFileSelection) { + const fileLabel = diffInfo.changedFiles.length === 1 ? "file" : "files"; + logger.log(`Scanning ${diffInfo.changedFiles.length} selected ${fileLabel}`); + } else if (diffInfo.isCurrentChanges) { logger.log("Scanning uncommitted changes"); } else { const currentBranchLabel = diffInfo.currentBranch ?? "(detached HEAD)"; diff --git a/packages/react-doctor/src/cli/commands/scan.ts b/packages/react-doctor/src/cli/commands/scan.ts index c28385255e..172f198599 100644 --- a/packages/react-doctor/src/cli/commands/scan.ts +++ b/packages/react-doctor/src/cli/commands/scan.ts @@ -9,22 +9,29 @@ import { resolveCliInspectOptions } from "../utils/resolve-cli-inspect-options.j import { resolveTuiEnvironment } from "../utils/resolve-tui-environment.js"; import { warnDeprecatedDiff } from "../utils/resolve-scope.js"; import { shouldUseTui } from "../utils/should-use-tui.js"; -import { validateModeFlags } from "../utils/validate-mode-flags.js"; +import { validateFilePathSelectionFlags, validateModeFlags } from "../utils/validate-mode-flags.js"; import { warnDeprecatedFailOn } from "../utils/warn-deprecated-fail-on.js"; export interface RunScanCommandInput { readonly directory: string; + readonly filePaths?: ReadonlyArray; readonly flags: InspectFlags; readonly invocationCommand: string; } export const runScanCommand = async (input: RunScanCommandInput): Promise => { if (input.flags.cache === false) process.env.REACT_DOCTOR_NO_CACHE = "1"; + if (input.filePaths !== undefined) validateFilePathSelectionFlags(input.flags); const tuiEnvironment = { flags: input.flags, ...resolveTuiEnvironment(), }; + if (input.filePaths !== undefined) { + await inspectAction(input.directory, input.flags, input.invocationCommand, input.filePaths); + return; + } + if (!shouldUseTui(tuiEnvironment)) { await inspectAction(input.directory, input.flags, input.invocationCommand); return; diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index 879a813adc..1db279f5e6 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -15,6 +15,7 @@ import { normalizeHelpInvocation } from "./utils/normalize-help-command.js"; import { printDebugTrace } from "./utils/print-debug-trace.js"; import { assertNoRemovedFlags } from "./utils/removed-cli-flags.js"; import { reportErrorToSentry } from "./utils/report-error.js"; +import { resolvePositionalScanInput } from "./utils/resolve-positional-scan-input.js"; import { stripUnknownCliFlags } from "./utils/strip-unknown-cli-flags.js"; import { unrefStdin } from "./utils/unref-stdin.js"; import { VERSION } from "./utils/version.js"; @@ -58,6 +59,7 @@ ${highlighter.dim("Examples:")} ${formatExampleLines([ ["react-doctor", "scan the current project"], ["react-doctor ./apps/web", "scan a specific directory"], + ["react-doctor src/a.tsx src/b.tsx", "scan only selected files"], ["react-doctor scan http://localhost:3000", "profile one interaction in a running React app"], ["react-doctor --scope changed --base main", "scan only new issues vs. main"], ["react-doctor --project modules/a,modules/b", "score each module separately (names or paths)"], @@ -180,7 +182,7 @@ const program = new Command() .name("react-doctor") .description("Diagnose React codebase health") .version(VERSION, "-v, --version", "display the version number") - .argument("[directory]", "project directory to scan", ".") + .argument("[paths...]", "one project directory or source file paths to scan") .option("--lint", "enable linting") .option("--no-lint", "skip linting") .addOption(new Option("--dead-code").hideHelp()) @@ -273,10 +275,11 @@ const program = new Command() .option("--no-color", "disable colored output (also honors NO_COLOR)") .addHelpText("after", renderRootHelpEpilog); -program.action(async (directory = ".", flags: InspectFlags) => { +program.action(async (positionalPaths: string[] = [], flags: InspectFlags) => { const { runScanCommand } = await import("./commands/scan.js"); + const scanInput = resolvePositionalScanInput(positionalPaths); return runScanCommand({ - directory, + ...scanInput, flags, invocationCommand: "inspect", }); diff --git a/packages/react-doctor/src/cli/utils/resolve-positional-scan-input.ts b/packages/react-doctor/src/cli/utils/resolve-positional-scan-input.ts new file mode 100644 index 0000000000..8f798ff9e6 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/resolve-positional-scan-input.ts @@ -0,0 +1,46 @@ +import * as path from "node:path"; +import { filterSourceFiles, isDirectory, isFile, isPathInsideDirectory } from "@react-doctor/core"; +import { CliInputError } from "./cli-input-error.js"; +import { toForwardSlashes } from "./path-format.js"; + +export interface PositionalScanInput { + readonly directory: string; + readonly filePaths: string[] | undefined; +} + +export const resolvePositionalScanInput = ( + positionalPaths: ReadonlyArray, + currentDirectory = process.cwd(), +): PositionalScanInput => { + const absoluteCurrentDirectory = path.resolve(currentDirectory); + if (positionalPaths.length === 0) { + return { directory: absoluteCurrentDirectory, filePaths: undefined }; + } + + const onlyAbsolutePath = path.resolve(absoluteCurrentDirectory, positionalPaths[0]); + const shouldUseDirectoryMode = + positionalPaths.length === 1 && + (isDirectory(onlyAbsolutePath) || + (!isFile(onlyAbsolutePath) && filterSourceFiles([positionalPaths[0]]).length === 0)); + if (shouldUseDirectoryMode) { + return { directory: positionalPaths[0], filePaths: undefined }; + } + + const uniqueFilePaths = new Set(); + for (const positionalPath of positionalPaths) { + const absolutePath = path.resolve(absoluteCurrentDirectory, positionalPath); + if (isDirectory(absolutePath)) { + throw new CliInputError( + `Cannot combine the directory "${positionalPath}" with file path arguments. Pass one directory or only file paths.`, + ); + } + if (!isPathInsideDirectory(absolutePath, absoluteCurrentDirectory)) { + throw new CliInputError( + `The file path "${positionalPath}" is outside the current directory. Run React Doctor from a common project root.`, + ); + } + uniqueFilePaths.add(toForwardSlashes(path.relative(absoluteCurrentDirectory, absolutePath))); + } + + return { directory: absoluteCurrentDirectory, filePaths: [...uniqueFilePaths] }; +}; diff --git a/packages/react-doctor/src/cli/utils/validate-mode-flags.ts b/packages/react-doctor/src/cli/utils/validate-mode-flags.ts index b20ec9fec0..1955ac1a20 100644 --- a/packages/react-doctor/src/cli/utils/validate-mode-flags.ts +++ b/packages/react-doctor/src/cli/utils/validate-mode-flags.ts @@ -25,6 +25,29 @@ export const validateIncludeUntrackedScope = ( ); }; +export const validateFilePathSelectionFlags = (flags: InspectFlags): void => { + if (flags.staged || flags.changedFilesFrom !== undefined) { + throw new CliInputError( + "Cannot combine file path arguments with --staged or --changed-files-from. Use one file source.", + ); + } + if (usedDiffAlias(flags)) { + throw new CliInputError( + "Cannot combine file path arguments with --diff. File path arguments use the files scope.", + ); + } + if (usedScope(flags) && flags.scope !== "files") { + throw new CliInputError( + `Cannot combine file path arguments with --scope ${flags.scope}. File path arguments use --scope files.`, + ); + } + if (flags.base !== undefined) { + throw new CliInputError( + "Cannot combine file path arguments with --base. The selected files do not need a Git base.", + ); + } +}; + export const validateModeFlags = (flags: InspectFlags): void => { if (usedScope(flags) && usedDiffAlias(flags)) { throw new CliInputError("Cannot combine --scope and --diff; --diff is the deprecated alias."); diff --git a/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts b/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts index 68ace36fcf..0cefe25d48 100644 --- a/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts +++ b/packages/react-doctor/tests/inspect-action-setup-prompt.test.ts @@ -241,6 +241,39 @@ describe("inspectAction setup prompt", () => { ); }); + it("scans positional file paths without resolving a Git diff", async () => { + const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-file-paths-")); + tempDirectories.push(rootDirectory); + const webDirectory = path.join(rootDirectory, "apps", "web"); + const adminDirectory = path.join(rootDirectory, "apps", "admin"); + writePackageJson(rootDirectory, { + name: "monorepo", + workspaces: ["apps/*"], + scripts: {}, + }); + writePackageJson(webDirectory, { name: "web", scripts: {} }); + writePackageJson(adminDirectory, { name: "admin", scripts: {} }); + mockState.projectDirectories = [webDirectory, adminDirectory]; + + await inspectAction(rootDirectory, { lint: false }, "inspect", [ + "apps/web/src/App.tsx", + "apps/admin/src/Dashboard.tsx", + ]); + + expect(mockState.lifecycleEvents).not.toContain("diff"); + expect(inspect).toHaveBeenCalledTimes(2); + expect(inspect).toHaveBeenNthCalledWith( + 1, + webDirectory, + expect.objectContaining({ includePaths: ["src/App.tsx"] }), + ); + expect(inspect).toHaveBeenNthCalledWith( + 2, + adminDirectory, + expect.objectContaining({ includePaths: ["src/Dashboard.tsx"] }), + ); + }); + it("rebases explicit changed-file paths after a rootDir redirect", async () => { const rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-rootdir-")); tempDirectories.push(rootDirectory); diff --git a/packages/react-doctor/tests/resolve-positional-scan-input.test.ts b/packages/react-doctor/tests/resolve-positional-scan-input.test.ts new file mode 100644 index 0000000000..84f1e10c09 --- /dev/null +++ b/packages/react-doctor/tests/resolve-positional-scan-input.test.ts @@ -0,0 +1,74 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { resolvePositionalScanInput } from "../src/cli/utils/resolve-positional-scan-input.js"; + +describe("resolvePositionalScanInput", () => { + let currentDirectory: string; + + beforeEach(() => { + currentDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-positional-input-")); + fs.mkdirSync(path.join(currentDirectory, "src")); + fs.writeFileSync(path.join(currentDirectory, "src", "a.tsx"), "export const A = () => null;\n"); + }); + + afterEach(() => { + fs.rmSync(currentDirectory, { recursive: true, force: true }); + }); + + it("uses the current directory when no path is passed", () => { + expect(resolvePositionalScanInput([], currentDirectory)).toEqual({ + directory: currentDirectory, + filePaths: undefined, + }); + }); + + it("keeps one directory as the scan directory", () => { + expect(resolvePositionalScanInput(["src"], currentDirectory)).toEqual({ + directory: "src", + filePaths: undefined, + }); + }); + + it("treats one source file as an explicit file selection", () => { + expect(resolvePositionalScanInput(["src/a.tsx"], currentDirectory)).toEqual({ + directory: currentDirectory, + filePaths: ["src/a.tsx"], + }); + }); + + it("normalizes and deduplicates several file paths", () => { + expect( + resolvePositionalScanInput( + ["./src/a.tsx", path.join(currentDirectory, "src", "a.tsx"), "src/missing.tsx"], + currentDirectory, + ), + ).toEqual({ + directory: currentDirectory, + filePaths: ["src/a.tsx", "src/missing.tsx"], + }); + }); + + it("keeps a missing non-source path compatible with directory scans", () => { + expect(resolvePositionalScanInput(["missing-project"], currentDirectory)).toEqual({ + directory: "missing-project", + filePaths: undefined, + }); + }); + + it("rejects a directory mixed with file paths", () => { + expect(() => resolvePositionalScanInput(["src", "src/a.tsx"], currentDirectory)).toThrow( + "Cannot combine the directory", + ); + }); + + it("rejects files outside the current directory", () => { + expect(() => + resolvePositionalScanInput( + [path.join(currentDirectory, "..", "outside.tsx")], + currentDirectory, + ), + ).toThrow("outside the current directory"); + }); +}); diff --git a/packages/react-doctor/tests/run-scan-command.test.ts b/packages/react-doctor/tests/run-scan-command.test.ts index 21a7bbddd1..91a9f3c07a 100644 --- a/packages/react-doctor/tests/run-scan-command.test.ts +++ b/packages/react-doctor/tests/run-scan-command.test.ts @@ -34,6 +34,7 @@ vi.mock("../src/cli/utils/resolve-scope.js", () => ({ })); vi.mock("../src/cli/utils/validate-mode-flags.js", () => ({ + validateFilePathSelectionFlags: vi.fn(), validateModeFlags: vi.fn(), })); @@ -131,6 +132,21 @@ describe("runScanCommand", () => { expect(recordCount).not.toHaveBeenCalled(); }); + it("uses headless output for an explicit file selection", async () => { + const flags = { scope: "files" }; + const filePaths = ["src/a.tsx", "src/b.tsx"]; + + await runScanCommand({ + directory: "/tmp/project", + filePaths, + flags, + invocationCommand: "inspect", + }); + + expect(inspectAction).toHaveBeenCalledWith("/tmp/project", flags, "inspect", filePaths); + expect(runScanApp).not.toHaveBeenCalled(); + }); + it("preserves the TUI scan exit code", async () => { vi.mocked(runScanApp).mockResolvedValue({ shouldFail: true }); diff --git a/packages/react-doctor/tests/validate-mode-flags.test.ts b/packages/react-doctor/tests/validate-mode-flags.test.ts index ff3fc4e0ab..65219e5535 100644 --- a/packages/react-doctor/tests/validate-mode-flags.test.ts +++ b/packages/react-doctor/tests/validate-mode-flags.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + validateFilePathSelectionFlags, validateIncludeUntrackedScope, validateModeFlags, } from "../src/cli/utils/validate-mode-flags.js"; @@ -64,6 +65,31 @@ describe("validateModeFlags", () => { }); }); +describe("validateFilePathSelectionFlags", () => { + it("allows file selection flags that do not change the file source", () => { + expect(() => validateFilePathSelectionFlags({ scope: "files", project: "app" })).not.toThrow(); + }); + + it("rejects another file source", () => { + expect(() => validateFilePathSelectionFlags({ staged: true })).toThrow("Use one file source"); + expect(() => validateFilePathSelectionFlags({ changedFilesFrom: "files.txt" })).toThrow( + "Use one file source", + ); + }); + + it("rejects Git-derived scopes", () => { + expect(() => validateFilePathSelectionFlags({ scope: "changed" })).toThrow( + "File path arguments use --scope files", + ); + expect(() => validateFilePathSelectionFlags({ diff: "main" })).toThrow( + "File path arguments use the files scope", + ); + expect(() => validateFilePathSelectionFlags({ base: "main" })).toThrow( + "selected files do not need a Git base", + ); + }); +}); + describe("validateIncludeUntrackedScope", () => { it("is a no-op when --include-untracked is off (any scope)", () => { expect(() => validateIncludeUntrackedScope(false, undefined)).not.toThrow(); From fd23edca7eaa76b7f2b66795cfc829cc1967b7f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:34:33 -0700 Subject: [PATCH 5/5] chore: version packages (#1649) --- .changeset/add-bippy-dependency.md | 5 -- .changeset/adopt-tailwind-settings.md | 6 --- .changeset/bump-oxc-toolchain.md | 6 --- .changeset/calm-compilers-rest.md | 6 --- .changeset/dependency-hygiene-1686.md | 5 -- .changeset/few-berries-drum.md | 10 ---- .changeset/fix-action-json-report.md | 5 -- .../fix-artifact-env-leak-source-maps.md | 5 -- .changeset/fix-ci-recommendation-monorepo.md | 5 -- .changeset/fix-expo-config-plugins.md | 5 -- .changeset/fix-expo-metro-config-subpaths.md | 5 -- .changeset/fix-expo-metro-config-wrappers.md | 5 -- .changeset/fix-fbt-component-return.md | 6 --- .changeset/fix-fbt-fragment-returns.md | 5 -- .changeset/fix-fbt-in-text-wrappers.md | 7 --- .../fix-function-resolution-stack-overflow.md | 5 -- .changeset/fix-implicit-subproject-entries.md | 5 -- .../fix-member-hook-state-consumption.md | 5 -- .changeset/fix-npm-cache-corruption-error.md | 5 -- .changeset/fix-recent-rule-false-positives.md | 5 -- .changeset/fix-scope-changed-remote-base.md | 5 -- .../fix-string-includes-false-positive.md | 6 --- .../fix-test-noise-application-paths.md | 7 --- .changeset/fix-type-only-window.md | 5 -- .changeset/fruity-wombats-teach.md | 5 -- .changeset/harden-react-doctor-ci.md | 5 -- .changeset/honest-flatmap-advice.md | 5 -- .changeset/polite-monkeys-laugh.md | 6 --- .changeset/quiet-agent-stop-hooks.md | 5 -- .changeset/quiet-taxis-smile.md | 5 -- .changeset/react-cleanup-engine.md | 6 --- .changeset/runtime-traces-browse.md | 5 -- .changeset/shadcn-composition-rules.md | 7 --- .changeset/tough-rules-juggle.md | 5 -- .changeset/upset-facts-hide.md | 5 -- .changeset/vast-numbers-roll.md | 5 -- packages/api/CHANGELOG.md | 7 +++ packages/api/package.json | 2 +- packages/core/CHANGELOG.md | 21 ++++++++ packages/core/package.json | 2 +- .../eslint-plugin-react-doctor/CHANGELOG.md | 15 ++++++ .../eslint-plugin-react-doctor/package.json | 2 +- packages/fuzz/CHANGELOG.md | 7 +++ packages/fuzz/package.json | 2 +- .../oxlint-plugin-react-doctor/CHANGELOG.md | 46 +++++++++++++++++ .../oxlint-plugin-react-doctor/package.json | 2 +- packages/react-doctor/CHANGELOG.md | 51 +++++++++++++++++++ packages/react-doctor/package.json | 2 +- 48 files changed, 153 insertions(+), 204 deletions(-) delete mode 100644 .changeset/add-bippy-dependency.md delete mode 100644 .changeset/adopt-tailwind-settings.md delete mode 100644 .changeset/bump-oxc-toolchain.md delete mode 100644 .changeset/calm-compilers-rest.md delete mode 100644 .changeset/dependency-hygiene-1686.md delete mode 100644 .changeset/few-berries-drum.md delete mode 100644 .changeset/fix-action-json-report.md delete mode 100644 .changeset/fix-artifact-env-leak-source-maps.md delete mode 100644 .changeset/fix-ci-recommendation-monorepo.md delete mode 100644 .changeset/fix-expo-config-plugins.md delete mode 100644 .changeset/fix-expo-metro-config-subpaths.md delete mode 100644 .changeset/fix-expo-metro-config-wrappers.md delete mode 100644 .changeset/fix-fbt-component-return.md delete mode 100644 .changeset/fix-fbt-fragment-returns.md delete mode 100644 .changeset/fix-fbt-in-text-wrappers.md delete mode 100644 .changeset/fix-function-resolution-stack-overflow.md delete mode 100644 .changeset/fix-implicit-subproject-entries.md delete mode 100644 .changeset/fix-member-hook-state-consumption.md delete mode 100644 .changeset/fix-npm-cache-corruption-error.md delete mode 100644 .changeset/fix-recent-rule-false-positives.md delete mode 100644 .changeset/fix-scope-changed-remote-base.md delete mode 100644 .changeset/fix-string-includes-false-positive.md delete mode 100644 .changeset/fix-test-noise-application-paths.md delete mode 100644 .changeset/fix-type-only-window.md delete mode 100644 .changeset/fruity-wombats-teach.md delete mode 100644 .changeset/harden-react-doctor-ci.md delete mode 100644 .changeset/honest-flatmap-advice.md delete mode 100644 .changeset/polite-monkeys-laugh.md delete mode 100644 .changeset/quiet-agent-stop-hooks.md delete mode 100644 .changeset/quiet-taxis-smile.md delete mode 100644 .changeset/react-cleanup-engine.md delete mode 100644 .changeset/runtime-traces-browse.md delete mode 100644 .changeset/shadcn-composition-rules.md delete mode 100644 .changeset/tough-rules-juggle.md delete mode 100644 .changeset/upset-facts-hide.md delete mode 100644 .changeset/vast-numbers-roll.md diff --git a/.changeset/add-bippy-dependency.md b/.changeset/add-bippy-dependency.md deleted file mode 100644 index 9f0dbafe3e..0000000000 --- a/.changeset/add-bippy-dependency.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Add bippy as a runtime dependency. diff --git a/.changeset/adopt-tailwind-settings.md b/.changeset/adopt-tailwind-settings.md deleted file mode 100644 index 5c1af5c5de..0000000000 --- a/.changeset/adopt-tailwind-settings.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@react-doctor/core": patch -"react-doctor": patch ---- - -Preserve plugin settings when React Doctor adopts an existing lint config. diff --git a/.changeset/bump-oxc-toolchain.md b/.changeset/bump-oxc-toolchain.md deleted file mode 100644 index 3969d689c2..0000000000 --- a/.changeset/bump-oxc-toolchain.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"react-doctor": patch -"oxlint-plugin-react-doctor": patch ---- - -Upgrade the Oxc parser and Oxlint runtime while preserving hard failures for broken JS plugins. diff --git a/.changeset/calm-compilers-rest.md b/.changeset/calm-compilers-rest.md deleted file mode 100644 index 15f1175238..0000000000 --- a/.changeset/calm-compilers-rest.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"eslint-plugin-react-doctor": patch -"oxlint-plugin-react-doctor": patch ---- - -Keep ESLint presets on React Doctor's curated low-noise rule behavior and honor configured capabilities when a rule declares `disabledWhen`, including suppressing manual-memoization diagnostics for React Compiler projects. diff --git a/.changeset/dependency-hygiene-1686.md b/.changeset/dependency-hygiene-1686.md deleted file mode 100644 index 67c8738e81..0000000000 --- a/.changeset/dependency-hygiene-1686.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Use pnpm's strict dependency layout and declare the runtime dependencies that the CLI imports directly. diff --git a/.changeset/few-berries-drum.md b/.changeset/few-berries-drum.md deleted file mode 100644 index a12a3058e0..0000000000 --- a/.changeset/few-berries-drum.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch -"react-doctor": patch ---- - -Keep the interactive score header intact in narrow split views and invalidate locally stale scan results when rule implementations change. - -Report standalone Three.js render loops that use `requestAnimationFrame` instead of the renderer-managed `setAnimationLoop` API. - -Include standalone Three.js, supported React framework, Remotion, and React Three Fiber ecosystem packages in automatic workspace project discovery. diff --git a/.changeset/fix-action-json-report.md b/.changeset/fix-action-json-report.md deleted file mode 100644 index 3dfc97399a..0000000000 --- a/.changeset/fix-action-json-report.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Clean leading npm messages from GitHub Action JSON reports before later steps read them. diff --git a/.changeset/fix-artifact-env-leak-source-maps.md b/.changeset/fix-artifact-env-leak-source-maps.md deleted file mode 100644 index 94f4f85716..0000000000 --- a/.changeset/fix-artifact-env-leak-source-maps.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Avoid `artifact-env-leak` false positives from vendored source-map content and intentionally public token names. diff --git a/.changeset/fix-ci-recommendation-monorepo.md b/.changeset/fix-ci-recommendation-monorepo.md deleted file mode 100644 index 7c3dc8ff1c..0000000000 --- a/.changeset/fix-ci-recommendation-monorepo.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Stop multi-project scans from recommending GitHub Actions when the root workflow is already configured. diff --git a/.changeset/fix-expo-config-plugins.md b/.changeset/fix-expo-config-plugins.md deleted file mode 100644 index cd54661f9d..0000000000 --- a/.changeset/fix-expo-config-plugins.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@react-doctor/core": patch ---- - -Recognize Expo config plugin packages and local plugin paths behind TypeScript `satisfies` expressions. diff --git a/.changeset/fix-expo-metro-config-subpaths.md b/.changeset/fix-expo-metro-config-subpaths.md deleted file mode 100644 index f6274e8080..0000000000 --- a/.changeset/fix-expo-metro-config-subpaths.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@react-doctor/core": patch ---- - -Keep `@expo/metro-config` as a direct dependency when project code imports a package subpath that the `expo/metro-config` umbrella does not expose. diff --git a/.changeset/fix-expo-metro-config-wrappers.md b/.changeset/fix-expo-metro-config-wrappers.md deleted file mode 100644 index 03137f35d4..0000000000 --- a/.changeset/fix-expo-metro-config-wrappers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@react-doctor/core": patch ---- - -Fix Expo Metro config false positives for local helpers and the PostHog Expo wrapper. diff --git a/.changeset/fix-fbt-component-return.md b/.changeset/fix-fbt-component-return.md deleted file mode 100644 index 191387ae05..0000000000 --- a/.changeset/fix-fbt-component-return.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch -"react-doctor": patch ---- - -Fix `rn-no-raw-text` false positives in components that return only direct `` or `` elements. diff --git a/.changeset/fix-fbt-fragment-returns.md b/.changeset/fix-fbt-fragment-returns.md deleted file mode 100644 index b363e66ecc..0000000000 --- a/.changeset/fix-fbt-fragment-returns.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Classify fragment returns that contain only translation elements and static text as text-producing components. diff --git a/.changeset/fix-fbt-in-text-wrappers.md b/.changeset/fix-fbt-in-text-wrappers.md deleted file mode 100644 index 803977fcbf..0000000000 --- a/.changeset/fix-fbt-in-text-wrappers.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch -"eslint-plugin-react-doctor": patch -"react-doctor": patch ---- - -Prevent `rn-no-raw-text` reports for `` content passed through verified React Native text wrappers. diff --git a/.changeset/fix-function-resolution-stack-overflow.md b/.changeset/fix-function-resolution-stack-overflow.md deleted file mode 100644 index 15b1d0b9b5..0000000000 --- a/.changeset/fix-function-resolution-stack-overflow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Prevent stack overflows while resolving deeply nested local function references. React Doctor now stops following a reference chain after a bounded number of steps instead of aborting the lint scan. diff --git a/.changeset/fix-implicit-subproject-entries.md b/.changeset/fix-implicit-subproject-entries.md deleted file mode 100644 index aa8f0f1edf..0000000000 --- a/.changeset/fix-implicit-subproject-entries.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@react-doctor/core": patch ---- - -Use entry points from discovered implicit subprojects outside declared workspace globs, preventing legitimate files from being reported as unused. diff --git a/.changeset/fix-member-hook-state-consumption.md b/.changeset/fix-member-hook-state-consumption.md deleted file mode 100644 index 3be1135453..0000000000 --- a/.changeset/fix-member-hook-state-consumption.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Fix a `rerender-state-only-in-handlers` false positive when a member hook consumes state. diff --git a/.changeset/fix-npm-cache-corruption-error.md b/.changeset/fix-npm-cache-corruption-error.md deleted file mode 100644 index faf98e08aa..0000000000 --- a/.changeset/fix-npm-cache-corruption-error.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Show an npm-native recovery command when an incomplete npx installation is missing Ajv meta-schema files. diff --git a/.changeset/fix-recent-rule-false-positives.md b/.changeset/fix-recent-rule-false-positives.md deleted file mode 100644 index e1d678f5e4..0000000000 --- a/.changeset/fix-recent-rule-false-positives.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Avoid false positives for loading resets in `finally`, animation duration utilities, and string message substring searches. diff --git a/.changeset/fix-scope-changed-remote-base.md b/.changeset/fix-scope-changed-remote-base.md deleted file mode 100644 index 0365958202..0000000000 --- a/.changeset/fix-scope-changed-remote-base.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@react-doctor/core": patch ---- - -Compare automatic changed scopes against the remote default branch, so committed branch changes are not mistaken for working-tree changes or skipped when no local default branch exists. diff --git a/.changeset/fix-string-includes-false-positive.md b/.changeset/fix-string-includes-false-positive.md deleted file mode 100644 index 2a5ac5f869..0000000000 --- a/.changeset/fix-string-includes-false-positive.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch -"react-doctor": patch ---- - -Fix `js-set-map-lookups` false positives for substring checks on values returned by the global `String` constructor. diff --git a/.changeset/fix-test-noise-application-paths.md b/.changeset/fix-test-noise-application-paths.md deleted file mode 100644 index 737e87bef3..0000000000 --- a/.changeset/fix-test-noise-application-paths.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch -"eslint-plugin-react-doctor": patch -"react-doctor": patch ---- - -Run `test-noise` rules in ambiguous product-named directories such as `tools`, `demo`, and `migrations` when they are below a recognized application source root. Explicit test surfaces and root-level tooling or example directories remain excluded. diff --git a/.changeset/fix-type-only-window.md b/.changeset/fix-type-only-window.md deleted file mode 100644 index bab0aa5321..0000000000 --- a/.changeset/fix-type-only-window.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Ignore browser-global names in TypeScript-only positions so interface and type property keys are not reported as unsafe module-scope runtime access. diff --git a/.changeset/fruity-wombats-teach.md b/.changeset/fruity-wombats-teach.md deleted file mode 100644 index 020815c6f0..0000000000 --- a/.changeset/fruity-wombats-teach.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Use the nearest workspace root when detecting Fast Refresh ownership so nested checkouts keep the correct rule coverage. diff --git a/.changeset/harden-react-doctor-ci.md b/.changeset/harden-react-doctor-ci.md deleted file mode 100644 index 873cb0a206..0000000000 --- a/.changeset/harden-react-doctor-ci.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Make generated GitHub workflows explain how to pin the action to an immutable commit SHA. diff --git a/.changeset/honest-flatmap-advice.md b/.changeset/honest-flatmap-advice.md deleted file mode 100644 index 9333724f79..0000000000 --- a/.changeset/honest-flatmap-advice.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Stop recommending `flatMap` as a guaranteed performance improvement for `.map().filter(Boolean)`. The rule now suggests a single-pass `reduce` or `for...of` rewrite only for measured hot paths. diff --git a/.changeset/polite-monkeys-laugh.md b/.changeset/polite-monkeys-laugh.md deleted file mode 100644 index 1f33e36acb..0000000000 --- a/.changeset/polite-monkeys-laugh.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch -"react-doctor": patch ---- - -Improve repeated effect analysis and deeply nested JSX performance, preserve derived-state detection through transparent TypeScript wrappers, and upgrade Oxc parser and linter dependencies. diff --git a/.changeset/quiet-agent-stop-hooks.md b/.changeset/quiet-agent-stop-hooks.md deleted file mode 100644 index e92fd9f275..0000000000 --- a/.changeset/quiet-agent-stop-hooks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Run installed Claude Code and Cursor hooks once at the end of an agent turn, include untracked files in the changed-file scan, and migrate existing per-tool React Doctor hooks automatically. diff --git a/.changeset/quiet-taxis-smile.md b/.changeset/quiet-taxis-smile.md deleted file mode 100644 index 006a40234d..0000000000 --- a/.changeset/quiet-taxis-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Accept source file paths as positional CLI arguments. diff --git a/.changeset/react-cleanup-engine.md b/.changeset/react-cleanup-engine.md deleted file mode 100644 index 2cd76906c6..0000000000 --- a/.changeset/react-cleanup-engine.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"react-doctor": patch -"oxlint-plugin-react-doctor": patch ---- - -Make React cleanup a first-class part of React Doctor with diagnostics for complex React functions and repeated JSX composition. Keep whole-project unused file, export, type, dependency, and import-cycle analysis as explicit opt-in rules while removing the separate Deslop packages, experimental language server, and IDE extensions. diff --git a/.changeset/runtime-traces-browse.md b/.changeset/runtime-traces-browse.md deleted file mode 100644 index d7da4143fe..0000000000 --- a/.changeset/runtime-traces-browse.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Add an interactive URL scan and `/performance` skill that record Chrome DevTools traces, flash live component render outlines, and return agent-readable React performance context. diff --git a/.changeset/shadcn-composition-rules.md b/.changeset/shadcn-composition-rules.md deleted file mode 100644 index 3965c75eaa..0000000000 --- a/.changeset/shadcn-composition-rules.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch -"eslint-plugin-react-doctor": patch -"react-doctor": patch ---- - -Add component-composition and correctness rules for shadcn, Radix UI, Base UI, React Aria, TanStack Table, and TanStack Virtual behind six new project capabilities (`shadcn` from `components.json`; the rest from their package dependencies). Dialog surfaces that render no title part and carry no accessible name are reported across all three libraries (shadcn DialogContent/SheetContent/AlertDialogContent/DrawerContent, Radix Dialog.Content and AlertDialog.Content, Base UI Dialog.Popup and AlertDialog.Popup). Icon-sized shadcn Buttons with no accessible name, shadcn FormItem fields wrapping a FormControl without a FormLabel, and Base UI Field.Root controls without a Field.Label are reported as unlabeled. Raw Input, Textarea, and Button controls placed directly inside shadcn InputGroup are reported in favor of its InputGroupInput, InputGroupTextarea, and InputGroupAddon parts, and presence-only `data-[selected]:` / `data-[disabled]:` Tailwind variants on command items are reported because cmdk renders both attributes as `"true"` or `"false"`. TanStack Form submit handlers that call the form's `handleSubmit` without `event.preventDefault()` are reported because the browser still performs a native full-page submission. Tabs triggers provably inside the root without the list part are reported for shadcn, Radix, and Base UI; the existing `shadcn-tabs-trigger-requires-list` rule is now enabled by default for shadcn projects through the capability gate and no longer risks false positives on extracted trigger subcomponents. React Aria Dialogs without a Heading or aria-label are reported as unnamed. TanStack Table `data`/`columns` options that provably get a new array identity every render (inline literals, render-scoped const arrays, fresh `?? []` fallbacks, inline `.filter()`/`.map()` transforms) are reported for rebuilding row and column models each render and looping auto-reset features, and elements measured by TanStack Virtual's `measureElement` without a `data-index` attribute are reported because the virtualizer drops the measurement. diff --git a/.changeset/tough-rules-juggle.md b/.changeset/tough-rules-juggle.md deleted file mode 100644 index b82937acd9..0000000000 --- a/.changeset/tough-rules-juggle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-doctor": patch ---- - -Normalize Oxlint file URLs before applying ignore patterns and writing report-relative diagnostic paths. diff --git a/.changeset/upset-facts-hide.md b/.changeset/upset-facts-hide.md deleted file mode 100644 index 56b18585b3..0000000000 --- a/.changeset/upset-facts-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"oxlint-plugin-react-doctor": patch ---- - -Avoid cleanup false positives for callback refs, observer iteration, and effect-local stored disposers. diff --git a/.changeset/vast-numbers-roll.md b/.changeset/vast-numbers-roll.md deleted file mode 100644 index dbd4db8728..0000000000 --- a/.changeset/vast-numbers-roll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@react-doctor/core": patch ---- - -Recover audit-mode source files after an interrupted process, without overwriting files that changed after the interruption. diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index 5d181d0bc3..7d7753b37b 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -1,5 +1,12 @@ # @react-doctor/api +## 0.9.13 + +### Patch Changes + +- Updated dependencies [[`ac87f7d`](https://github.com/millionco/react-doctor/commit/ac87f7d7f64d77cc0a648ee863dd50b3c69c0257), [`40b9d79`](https://github.com/millionco/react-doctor/commit/40b9d79703c398f47ba92488ce1bdea011f12109), [`6adf55e`](https://github.com/millionco/react-doctor/commit/6adf55ed8841c9a6f63a3e93cc0d2ccfbe4ca852), [`990daaf`](https://github.com/millionco/react-doctor/commit/990daafa6277b966aab30152a8e23c194bcce738), [`77aec24`](https://github.com/millionco/react-doctor/commit/77aec24f42fa8a2c55504550df929ea3985b7748), [`79d8007`](https://github.com/millionco/react-doctor/commit/79d80072817eb86c74f3dd42ce91c8104f448810), [`a163de9`](https://github.com/millionco/react-doctor/commit/a163de9afa0c4a84c2d6e13ddd7ac55c910dacc3)]: + - @react-doctor/core@0.9.13 + ## 0.9.12 ### Patch Changes diff --git a/packages/api/package.json b/packages/api/package.json index 7ce7285ea1..8f0b7de895 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@react-doctor/api", - "version": "0.9.12", + "version": "0.9.13", "private": true, "description": "Programmatic API for React Doctor.", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7c786e0dd1..986b6739f5 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,26 @@ # @react-doctor/core +## 0.9.13 + +### Patch Changes + +- [#1695](https://github.com/millionco/react-doctor/pull/1695) [`ac87f7d`](https://github.com/millionco/react-doctor/commit/ac87f7d7f64d77cc0a648ee863dd50b3c69c0257) Thanks [@skoshx](https://github.com/skoshx)! - Preserve plugin settings when React Doctor adopts an existing lint config. + +- [#1713](https://github.com/millionco/react-doctor/pull/1713) [`40b9d79`](https://github.com/millionco/react-doctor/commit/40b9d79703c398f47ba92488ce1bdea011f12109) Thanks [@aidenybai](https://github.com/aidenybai)! - Recognize Expo config plugin packages and local plugin paths behind TypeScript `satisfies` expressions. + +- [#1728](https://github.com/millionco/react-doctor/pull/1728) [`6adf55e`](https://github.com/millionco/react-doctor/commit/6adf55ed8841c9a6f63a3e93cc0d2ccfbe4ca852) Thanks [@skoshx](https://github.com/skoshx)! - Keep `@expo/metro-config` as a direct dependency when project code imports a package subpath that the `expo/metro-config` umbrella does not expose. + +- [#1718](https://github.com/millionco/react-doctor/pull/1718) [`990daaf`](https://github.com/millionco/react-doctor/commit/990daafa6277b966aab30152a8e23c194bcce738) Thanks [@skoshx](https://github.com/skoshx)! - Fix Expo Metro config false positives for local helpers and the PostHog Expo wrapper. + +- [#1666](https://github.com/millionco/react-doctor/pull/1666) [`77aec24`](https://github.com/millionco/react-doctor/commit/77aec24f42fa8a2c55504550df929ea3985b7748) Thanks [@skoshx](https://github.com/skoshx)! - Use entry points from discovered implicit subprojects outside declared workspace globs, preventing legitimate files from being reported as unused. + +- [#1676](https://github.com/millionco/react-doctor/pull/1676) [`79d8007`](https://github.com/millionco/react-doctor/commit/79d80072817eb86c74f3dd42ce91c8104f448810) Thanks [@aidenybai](https://github.com/aidenybai)! - Compare automatic changed scopes against the remote default branch, so committed branch changes are not mistaken for working-tree changes or skipped when no local default branch exists. + +- [#1690](https://github.com/millionco/react-doctor/pull/1690) [`a163de9`](https://github.com/millionco/react-doctor/commit/a163de9afa0c4a84c2d6e13ddd7ac55c910dacc3) Thanks [@skoshx](https://github.com/skoshx)! - Recover audit-mode source files after an interrupted process, without overwriting files that changed after the interruption. + +- Updated dependencies [[`ffc2d14`](https://github.com/millionco/react-doctor/commit/ffc2d142545167107b11908f004d764ac4e31399), [`f7efb7d`](https://github.com/millionco/react-doctor/commit/f7efb7d1c4fc564fa647a0dc26c48867da9166c9), [`05ef989`](https://github.com/millionco/react-doctor/commit/05ef98926de787b01e817c8853101d6c31e2071a), [`a04b933`](https://github.com/millionco/react-doctor/commit/a04b933c027f6addf4161ba0df1c11eb8922b879), [`adc3a91`](https://github.com/millionco/react-doctor/commit/adc3a9129190315263a5fa92bda7ea3e3e2ba94a), [`2c4560f`](https://github.com/millionco/react-doctor/commit/2c4560fc0abbf70f1574fe847402d320347d061e), [`e1d4c51`](https://github.com/millionco/react-doctor/commit/e1d4c51abfd9d15ec96f5001259c3e8f332f7d50), [`905607f`](https://github.com/millionco/react-doctor/commit/905607f7fc2240304cbad5f41d3ad496eab06b17), [`17eeeb5`](https://github.com/millionco/react-doctor/commit/17eeeb5367177e6a3ba814ca8d107d009addc9dc), [`afa1780`](https://github.com/millionco/react-doctor/commit/afa1780254bfd72175e6d0025841560582d32ad1), [`025d69d`](https://github.com/millionco/react-doctor/commit/025d69d701581092632caa87ea59e5a719094ab9), [`0f59a3b`](https://github.com/millionco/react-doctor/commit/0f59a3b84dd5233f6bcf5e4a621da6699c432405), [`5bc88ae`](https://github.com/millionco/react-doctor/commit/5bc88ae6a0cd7518ffa8c6348f9176868d00ea77), [`4bf7aff`](https://github.com/millionco/react-doctor/commit/4bf7aff4398383adb6b3dace48f72050dfd195a6), [`bd08406`](https://github.com/millionco/react-doctor/commit/bd08406381618785181aedf8bee956047ad107d3), [`2b0f06e`](https://github.com/millionco/react-doctor/commit/2b0f06ec70943f083d8893f8a1b989eba2ae40c6), [`8c2f03a`](https://github.com/millionco/react-doctor/commit/8c2f03aea9885f24da8f2002e85a32ac186bf5bf), [`6416370`](https://github.com/millionco/react-doctor/commit/6416370836deaa0a09189343a8579fb3f5d13494), [`28d4343`](https://github.com/millionco/react-doctor/commit/28d4343e4d90a8d80c0fdb5eac0173bdd8826866)]: + - oxlint-plugin-react-doctor@0.9.13 + ## 0.9.12 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index ec23ecd2d9..e08c62ca1d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@react-doctor/core", - "version": "0.9.12", + "version": "0.9.13", "private": true, "description": "Diagnostic engine for React Doctor.", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/eslint-plugin-react-doctor/CHANGELOG.md b/packages/eslint-plugin-react-doctor/CHANGELOG.md index c159c332e4..ecdd42d6c5 100644 --- a/packages/eslint-plugin-react-doctor/CHANGELOG.md +++ b/packages/eslint-plugin-react-doctor/CHANGELOG.md @@ -1,5 +1,20 @@ # eslint-plugin-react-doctor +## 0.9.13 + +### Patch Changes + +- [#1652](https://github.com/millionco/react-doctor/pull/1652) [`f7efb7d`](https://github.com/millionco/react-doctor/commit/f7efb7d1c4fc564fa647a0dc26c48867da9166c9) Thanks [@aidenybai](https://github.com/aidenybai)! - Keep ESLint presets on React Doctor's curated low-noise rule behavior and honor configured capabilities when a rule declares `disabledWhen`, including suppressing manual-memoization diagnostics for React Compiler projects. + +- [#1723](https://github.com/millionco/react-doctor/pull/1723) [`e1d4c51`](https://github.com/millionco/react-doctor/commit/e1d4c51abfd9d15ec96f5001259c3e8f332f7d50) Thanks [@skoshx](https://github.com/skoshx)! - Prevent `rn-no-raw-text` reports for `` content passed through verified React Native text wrappers. + +- [#1725](https://github.com/millionco/react-doctor/pull/1725) [`0f59a3b`](https://github.com/millionco/react-doctor/commit/0f59a3b84dd5233f6bcf5e4a621da6699c432405) Thanks [@aidenybai](https://github.com/aidenybai)! - Run `test-noise` rules in ambiguous product-named directories such as `tools`, `demo`, and `migrations` when they are below a recognized application source root. Explicit test surfaces and root-level tooling or example directories remain excluded. + +- [#1654](https://github.com/millionco/react-doctor/pull/1654) [`6416370`](https://github.com/millionco/react-doctor/commit/6416370836deaa0a09189343a8579fb3f5d13494) Thanks [@aidenybai](https://github.com/aidenybai)! - Add component-composition and correctness rules for shadcn, Radix UI, Base UI, React Aria, TanStack Table, and TanStack Virtual behind six new project capabilities (`shadcn` from `components.json`; the rest from their package dependencies). Dialog surfaces that render no title part and carry no accessible name are reported across all three libraries (shadcn DialogContent/SheetContent/AlertDialogContent/DrawerContent, Radix Dialog.Content and AlertDialog.Content, Base UI Dialog.Popup and AlertDialog.Popup). Icon-sized shadcn Buttons with no accessible name, shadcn FormItem fields wrapping a FormControl without a FormLabel, and Base UI Field.Root controls without a Field.Label are reported as unlabeled. Raw Input, Textarea, and Button controls placed directly inside shadcn InputGroup are reported in favor of its InputGroupInput, InputGroupTextarea, and InputGroupAddon parts, and presence-only `data-[selected]:` / `data-[disabled]:` Tailwind variants on command items are reported because cmdk renders both attributes as `"true"` or `"false"`. TanStack Form submit handlers that call the form's `handleSubmit` without `event.preventDefault()` are reported because the browser still performs a native full-page submission. Tabs triggers provably inside the root without the list part are reported for shadcn, Radix, and Base UI; the existing `shadcn-tabs-trigger-requires-list` rule is now enabled by default for shadcn projects through the capability gate and no longer risks false positives on extracted trigger subcomponents. React Aria Dialogs without a Heading or aria-label are reported as unnamed. TanStack Table `data`/`columns` options that provably get a new array identity every render (inline literals, render-scoped const arrays, fresh `?? []` fallbacks, inline `.filter()`/`.map()` transforms) are reported for rebuilding row and column models each render and looping auto-reset features, and elements measured by TanStack Virtual's `measureElement` without a `data-index` attribute are reported because the virtualizer drops the measurement. + +- Updated dependencies [[`ffc2d14`](https://github.com/millionco/react-doctor/commit/ffc2d142545167107b11908f004d764ac4e31399), [`f7efb7d`](https://github.com/millionco/react-doctor/commit/f7efb7d1c4fc564fa647a0dc26c48867da9166c9), [`05ef989`](https://github.com/millionco/react-doctor/commit/05ef98926de787b01e817c8853101d6c31e2071a), [`a04b933`](https://github.com/millionco/react-doctor/commit/a04b933c027f6addf4161ba0df1c11eb8922b879), [`adc3a91`](https://github.com/millionco/react-doctor/commit/adc3a9129190315263a5fa92bda7ea3e3e2ba94a), [`2c4560f`](https://github.com/millionco/react-doctor/commit/2c4560fc0abbf70f1574fe847402d320347d061e), [`e1d4c51`](https://github.com/millionco/react-doctor/commit/e1d4c51abfd9d15ec96f5001259c3e8f332f7d50), [`905607f`](https://github.com/millionco/react-doctor/commit/905607f7fc2240304cbad5f41d3ad496eab06b17), [`17eeeb5`](https://github.com/millionco/react-doctor/commit/17eeeb5367177e6a3ba814ca8d107d009addc9dc), [`afa1780`](https://github.com/millionco/react-doctor/commit/afa1780254bfd72175e6d0025841560582d32ad1), [`025d69d`](https://github.com/millionco/react-doctor/commit/025d69d701581092632caa87ea59e5a719094ab9), [`0f59a3b`](https://github.com/millionco/react-doctor/commit/0f59a3b84dd5233f6bcf5e4a621da6699c432405), [`5bc88ae`](https://github.com/millionco/react-doctor/commit/5bc88ae6a0cd7518ffa8c6348f9176868d00ea77), [`4bf7aff`](https://github.com/millionco/react-doctor/commit/4bf7aff4398383adb6b3dace48f72050dfd195a6), [`bd08406`](https://github.com/millionco/react-doctor/commit/bd08406381618785181aedf8bee956047ad107d3), [`2b0f06e`](https://github.com/millionco/react-doctor/commit/2b0f06ec70943f083d8893f8a1b989eba2ae40c6), [`8c2f03a`](https://github.com/millionco/react-doctor/commit/8c2f03aea9885f24da8f2002e85a32ac186bf5bf), [`6416370`](https://github.com/millionco/react-doctor/commit/6416370836deaa0a09189343a8579fb3f5d13494), [`28d4343`](https://github.com/millionco/react-doctor/commit/28d4343e4d90a8d80c0fdb5eac0173bdd8826866)]: + - oxlint-plugin-react-doctor@0.9.13 + ## 0.9.12 ### Patch Changes diff --git a/packages/eslint-plugin-react-doctor/package.json b/packages/eslint-plugin-react-doctor/package.json index 97b90aac95..b99fcffb10 100644 --- a/packages/eslint-plugin-react-doctor/package.json +++ b/packages/eslint-plugin-react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-doctor", - "version": "0.9.12", + "version": "0.9.13", "description": "React Doctor rules for ESLint.", "keywords": [ "accessibility", diff --git a/packages/fuzz/CHANGELOG.md b/packages/fuzz/CHANGELOG.md index 10b1a9cf28..067f08f55a 100644 --- a/packages/fuzz/CHANGELOG.md +++ b/packages/fuzz/CHANGELOG.md @@ -1,5 +1,12 @@ # @react-doctor/fuzz +## 0.0.31 + +### Patch Changes + +- Updated dependencies [[`ffc2d14`](https://github.com/millionco/react-doctor/commit/ffc2d142545167107b11908f004d764ac4e31399), [`f7efb7d`](https://github.com/millionco/react-doctor/commit/f7efb7d1c4fc564fa647a0dc26c48867da9166c9), [`05ef989`](https://github.com/millionco/react-doctor/commit/05ef98926de787b01e817c8853101d6c31e2071a), [`a04b933`](https://github.com/millionco/react-doctor/commit/a04b933c027f6addf4161ba0df1c11eb8922b879), [`adc3a91`](https://github.com/millionco/react-doctor/commit/adc3a9129190315263a5fa92bda7ea3e3e2ba94a), [`2c4560f`](https://github.com/millionco/react-doctor/commit/2c4560fc0abbf70f1574fe847402d320347d061e), [`e1d4c51`](https://github.com/millionco/react-doctor/commit/e1d4c51abfd9d15ec96f5001259c3e8f332f7d50), [`905607f`](https://github.com/millionco/react-doctor/commit/905607f7fc2240304cbad5f41d3ad496eab06b17), [`17eeeb5`](https://github.com/millionco/react-doctor/commit/17eeeb5367177e6a3ba814ca8d107d009addc9dc), [`afa1780`](https://github.com/millionco/react-doctor/commit/afa1780254bfd72175e6d0025841560582d32ad1), [`025d69d`](https://github.com/millionco/react-doctor/commit/025d69d701581092632caa87ea59e5a719094ab9), [`0f59a3b`](https://github.com/millionco/react-doctor/commit/0f59a3b84dd5233f6bcf5e4a621da6699c432405), [`5bc88ae`](https://github.com/millionco/react-doctor/commit/5bc88ae6a0cd7518ffa8c6348f9176868d00ea77), [`4bf7aff`](https://github.com/millionco/react-doctor/commit/4bf7aff4398383adb6b3dace48f72050dfd195a6), [`bd08406`](https://github.com/millionco/react-doctor/commit/bd08406381618785181aedf8bee956047ad107d3), [`2b0f06e`](https://github.com/millionco/react-doctor/commit/2b0f06ec70943f083d8893f8a1b989eba2ae40c6), [`8c2f03a`](https://github.com/millionco/react-doctor/commit/8c2f03aea9885f24da8f2002e85a32ac186bf5bf), [`6416370`](https://github.com/millionco/react-doctor/commit/6416370836deaa0a09189343a8579fb3f5d13494), [`28d4343`](https://github.com/millionco/react-doctor/commit/28d4343e4d90a8d80c0fdb5eac0173bdd8826866)]: + - oxlint-plugin-react-doctor@0.9.13 + ## 0.0.30 ### Patch Changes diff --git a/packages/fuzz/package.json b/packages/fuzz/package.json index 5efba6aa1e..70285fa85e 100644 --- a/packages/fuzz/package.json +++ b/packages/fuzz/package.json @@ -1,6 +1,6 @@ { "name": "@react-doctor/fuzz", - "version": "0.0.30", + "version": "0.0.31", "private": true, "description": "Adversarial fuzzing harness for React Doctor rules.", "license": "SEE LICENSE IN LICENSE", diff --git a/packages/oxlint-plugin-react-doctor/CHANGELOG.md b/packages/oxlint-plugin-react-doctor/CHANGELOG.md index a4340b2626..e8923d5ce0 100644 --- a/packages/oxlint-plugin-react-doctor/CHANGELOG.md +++ b/packages/oxlint-plugin-react-doctor/CHANGELOG.md @@ -1,5 +1,51 @@ # oxlint-plugin-react-doctor +## 0.9.13 + +### Patch Changes + +- [#1651](https://github.com/millionco/react-doctor/pull/1651) [`ffc2d14`](https://github.com/millionco/react-doctor/commit/ffc2d142545167107b11908f004d764ac4e31399) Thanks [@aidenybai](https://github.com/aidenybai)! - Upgrade the Oxc parser and Oxlint runtime while preserving hard failures for broken JS plugins. + +- [#1652](https://github.com/millionco/react-doctor/pull/1652) [`f7efb7d`](https://github.com/millionco/react-doctor/commit/f7efb7d1c4fc564fa647a0dc26c48867da9166c9) Thanks [@aidenybai](https://github.com/aidenybai)! - Keep ESLint presets on React Doctor's curated low-noise rule behavior and honor configured capabilities when a rule declares `disabledWhen`, including suppressing manual-memoization diagnostics for React Compiler projects. + +- [#1646](https://github.com/millionco/react-doctor/pull/1646) [`05ef989`](https://github.com/millionco/react-doctor/commit/05ef98926de787b01e817c8853101d6c31e2071a) Thanks [@aidenybai](https://github.com/aidenybai)! - Keep the interactive score header intact in narrow split views and invalidate locally stale scan results when rule implementations change. + + Report standalone Three.js render loops that use `requestAnimationFrame` instead of the renderer-managed `setAnimationLoop` API. + + Include standalone Three.js, supported React framework, Remotion, and React Three Fiber ecosystem packages in automatic workspace project discovery. + +- [#1739](https://github.com/millionco/react-doctor/pull/1739) [`a04b933`](https://github.com/millionco/react-doctor/commit/a04b933c027f6addf4161ba0df1c11eb8922b879) Thanks [@aidenybai](https://github.com/aidenybai)! - Avoid `artifact-env-leak` false positives from vendored source-map content and intentionally public token names. + +- [#1730](https://github.com/millionco/react-doctor/pull/1730) [`adc3a91`](https://github.com/millionco/react-doctor/commit/adc3a9129190315263a5fa92bda7ea3e3e2ba94a) Thanks [@skoshx](https://github.com/skoshx)! - Fix `rn-no-raw-text` false positives in components that return only direct `` or `` elements. + +- [#1732](https://github.com/millionco/react-doctor/pull/1732) [`2c4560f`](https://github.com/millionco/react-doctor/commit/2c4560fc0abbf70f1574fe847402d320347d061e) Thanks [@skoshx](https://github.com/skoshx)! - Classify fragment returns that contain only translation elements and static text as text-producing components. + +- [#1723](https://github.com/millionco/react-doctor/pull/1723) [`e1d4c51`](https://github.com/millionco/react-doctor/commit/e1d4c51abfd9d15ec96f5001259c3e8f332f7d50) Thanks [@skoshx](https://github.com/skoshx)! - Prevent `rn-no-raw-text` reports for `` content passed through verified React Native text wrappers. + +- [#1658](https://github.com/millionco/react-doctor/pull/1658) [`905607f`](https://github.com/millionco/react-doctor/commit/905607f7fc2240304cbad5f41d3ad496eab06b17) Thanks [@skoshx](https://github.com/skoshx)! - Prevent stack overflows while resolving deeply nested local function references. React Doctor now stops following a reference chain after a bounded number of steps instead of aborting the lint scan. + +- [#1717](https://github.com/millionco/react-doctor/pull/1717) [`17eeeb5`](https://github.com/millionco/react-doctor/commit/17eeeb5367177e6a3ba814ca8d107d009addc9dc) Thanks [@skoshx](https://github.com/skoshx)! - Fix a `rerender-state-only-in-handlers` false positive when a member hook consumes state. + +- [#1706](https://github.com/millionco/react-doctor/pull/1706) [`afa1780`](https://github.com/millionco/react-doctor/commit/afa1780254bfd72175e6d0025841560582d32ad1) Thanks [@aidenybai](https://github.com/aidenybai)! - Avoid false positives for loading resets in `finally`, animation duration utilities, and string message substring searches. + +- [#1734](https://github.com/millionco/react-doctor/pull/1734) [`025d69d`](https://github.com/millionco/react-doctor/commit/025d69d701581092632caa87ea59e5a719094ab9) Thanks [@skoshx](https://github.com/skoshx)! - Fix `js-set-map-lookups` false positives for substring checks on values returned by the global `String` constructor. + +- [#1725](https://github.com/millionco/react-doctor/pull/1725) [`0f59a3b`](https://github.com/millionco/react-doctor/commit/0f59a3b84dd5233f6bcf5e4a621da6699c432405) Thanks [@aidenybai](https://github.com/aidenybai)! - Run `test-noise` rules in ambiguous product-named directories such as `tools`, `demo`, and `migrations` when they are below a recognized application source root. Explicit test surfaces and root-level tooling or example directories remain excluded. + +- [#1668](https://github.com/millionco/react-doctor/pull/1668) [`5bc88ae`](https://github.com/millionco/react-doctor/commit/5bc88ae6a0cd7518ffa8c6348f9176868d00ea77) Thanks [@skoshx](https://github.com/skoshx)! - Ignore browser-global names in TypeScript-only positions so interface and type property keys are not reported as unsafe module-scope runtime access. + +- [#1673](https://github.com/millionco/react-doctor/pull/1673) [`4bf7aff`](https://github.com/millionco/react-doctor/commit/4bf7aff4398383adb6b3dace48f72050dfd195a6) Thanks [@aidenybai](https://github.com/aidenybai)! - Use the nearest workspace root when detecting Fast Refresh ownership so nested checkouts keep the correct rule coverage. + +- [#1671](https://github.com/millionco/react-doctor/pull/1671) [`bd08406`](https://github.com/millionco/react-doctor/commit/bd08406381618785181aedf8bee956047ad107d3) Thanks [@aidenybai](https://github.com/aidenybai)! - Stop recommending `flatMap` as a guaranteed performance improvement for `.map().filter(Boolean)`. The rule now suggests a single-pass `reduce` or `for...of` rewrite only for measured hot paths. + +- [#1663](https://github.com/millionco/react-doctor/pull/1663) [`2b0f06e`](https://github.com/millionco/react-doctor/commit/2b0f06ec70943f083d8893f8a1b989eba2ae40c6) Thanks [@aidenybai](https://github.com/aidenybai)! - Improve repeated effect analysis and deeply nested JSX performance, preserve derived-state detection through transparent TypeScript wrappers, and upgrade Oxc parser and linter dependencies. + +- [#1624](https://github.com/millionco/react-doctor/pull/1624) [`8c2f03a`](https://github.com/millionco/react-doctor/commit/8c2f03aea9885f24da8f2002e85a32ac186bf5bf) Thanks [@aidenybai](https://github.com/aidenybai)! - Make React cleanup a first-class part of React Doctor with diagnostics for complex React functions and repeated JSX composition. Keep whole-project unused file, export, type, dependency, and import-cycle analysis as explicit opt-in rules while removing the separate Deslop packages, experimental language server, and IDE extensions. + +- [#1654](https://github.com/millionco/react-doctor/pull/1654) [`6416370`](https://github.com/millionco/react-doctor/commit/6416370836deaa0a09189343a8579fb3f5d13494) Thanks [@aidenybai](https://github.com/aidenybai)! - Add component-composition and correctness rules for shadcn, Radix UI, Base UI, React Aria, TanStack Table, and TanStack Virtual behind six new project capabilities (`shadcn` from `components.json`; the rest from their package dependencies). Dialog surfaces that render no title part and carry no accessible name are reported across all three libraries (shadcn DialogContent/SheetContent/AlertDialogContent/DrawerContent, Radix Dialog.Content and AlertDialog.Content, Base UI Dialog.Popup and AlertDialog.Popup). Icon-sized shadcn Buttons with no accessible name, shadcn FormItem fields wrapping a FormControl without a FormLabel, and Base UI Field.Root controls without a Field.Label are reported as unlabeled. Raw Input, Textarea, and Button controls placed directly inside shadcn InputGroup are reported in favor of its InputGroupInput, InputGroupTextarea, and InputGroupAddon parts, and presence-only `data-[selected]:` / `data-[disabled]:` Tailwind variants on command items are reported because cmdk renders both attributes as `"true"` or `"false"`. TanStack Form submit handlers that call the form's `handleSubmit` without `event.preventDefault()` are reported because the browser still performs a native full-page submission. Tabs triggers provably inside the root without the list part are reported for shadcn, Radix, and Base UI; the existing `shadcn-tabs-trigger-requires-list` rule is now enabled by default for shadcn projects through the capability gate and no longer risks false positives on extracted trigger subcomponents. React Aria Dialogs without a Heading or aria-label are reported as unnamed. TanStack Table `data`/`columns` options that provably get a new array identity every render (inline literals, render-scoped const arrays, fresh `?? []` fallbacks, inline `.filter()`/`.map()` transforms) are reported for rebuilding row and column models each render and looping auto-reset features, and elements measured by TanStack Virtual's `measureElement` without a `data-index` attribute are reported because the virtualizer drops the measurement. + +- [#1742](https://github.com/millionco/react-doctor/pull/1742) [`28d4343`](https://github.com/millionco/react-doctor/commit/28d4343e4d90a8d80c0fdb5eac0173bdd8826866) Thanks [@aidenybai](https://github.com/aidenybai)! - Avoid cleanup false positives for callback refs, observer iteration, and effect-local stored disposers. + ## 0.9.12 ### Patch Changes diff --git a/packages/oxlint-plugin-react-doctor/package.json b/packages/oxlint-plugin-react-doctor/package.json index e10e883b7e..4350e8aa81 100644 --- a/packages/oxlint-plugin-react-doctor/package.json +++ b/packages/oxlint-plugin-react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "oxlint-plugin-react-doctor", - "version": "0.9.12", + "version": "0.9.13", "description": "React Doctor rules for oxlint.", "keywords": [ "accessibility", diff --git a/packages/react-doctor/CHANGELOG.md b/packages/react-doctor/CHANGELOG.md index bf6e65269e..77061777fc 100644 --- a/packages/react-doctor/CHANGELOG.md +++ b/packages/react-doctor/CHANGELOG.md @@ -1,5 +1,56 @@ # react-doctor +## 0.9.13 + +### Patch Changes + +- [`28a1a9f`](https://github.com/millionco/react-doctor/commit/28a1a9fd35d41b6871a6696ea8e04494aab907ba) Thanks [@aidenybai](https://github.com/aidenybai)! - Add bippy as a runtime dependency. + +- [#1695](https://github.com/millionco/react-doctor/pull/1695) [`ac87f7d`](https://github.com/millionco/react-doctor/commit/ac87f7d7f64d77cc0a648ee863dd50b3c69c0257) Thanks [@skoshx](https://github.com/skoshx)! - Preserve plugin settings when React Doctor adopts an existing lint config. + +- [#1651](https://github.com/millionco/react-doctor/pull/1651) [`ffc2d14`](https://github.com/millionco/react-doctor/commit/ffc2d142545167107b11908f004d764ac4e31399) Thanks [@aidenybai](https://github.com/aidenybai)! - Upgrade the Oxc parser and Oxlint runtime while preserving hard failures for broken JS plugins. + +- [#1689](https://github.com/millionco/react-doctor/pull/1689) [`1d3e4a6`](https://github.com/millionco/react-doctor/commit/1d3e4a606192ac949360371d98838abb1fb9e47d) Thanks [@skoshx](https://github.com/skoshx)! - Use pnpm's strict dependency layout and declare the runtime dependencies that the CLI imports directly. + +- [#1646](https://github.com/millionco/react-doctor/pull/1646) [`05ef989`](https://github.com/millionco/react-doctor/commit/05ef98926de787b01e817c8853101d6c31e2071a) Thanks [@aidenybai](https://github.com/aidenybai)! - Keep the interactive score header intact in narrow split views and invalidate locally stale scan results when rule implementations change. + + Report standalone Three.js render loops that use `requestAnimationFrame` instead of the renderer-managed `setAnimationLoop` API. + + Include standalone Three.js, supported React framework, Remotion, and React Three Fiber ecosystem packages in automatic workspace project discovery. + +- [#1714](https://github.com/millionco/react-doctor/pull/1714) [`013f737`](https://github.com/millionco/react-doctor/commit/013f7373f91a3b9e68bd1dc7d4d354f4b041b117) Thanks [@aidenybai](https://github.com/aidenybai)! - Clean leading npm messages from GitHub Action JSON reports before later steps read them. + +- [#1697](https://github.com/millionco/react-doctor/pull/1697) [`0557145`](https://github.com/millionco/react-doctor/commit/0557145cf3d10ab0a6359babb1604208c1e65863) Thanks [@skoshx](https://github.com/skoshx)! - Stop multi-project scans from recommending GitHub Actions when the root workflow is already configured. + +- [#1730](https://github.com/millionco/react-doctor/pull/1730) [`adc3a91`](https://github.com/millionco/react-doctor/commit/adc3a9129190315263a5fa92bda7ea3e3e2ba94a) Thanks [@skoshx](https://github.com/skoshx)! - Fix `rn-no-raw-text` false positives in components that return only direct `` or `` elements. + +- [#1723](https://github.com/millionco/react-doctor/pull/1723) [`e1d4c51`](https://github.com/millionco/react-doctor/commit/e1d4c51abfd9d15ec96f5001259c3e8f332f7d50) Thanks [@skoshx](https://github.com/skoshx)! - Prevent `rn-no-raw-text` reports for `` content passed through verified React Native text wrappers. + +- [#1693](https://github.com/millionco/react-doctor/pull/1693) [`4e04921`](https://github.com/millionco/react-doctor/commit/4e049212052a5433e1995cc6353a0fcde3c8a2e1) Thanks [@skoshx](https://github.com/skoshx)! - Show an npm-native recovery command when an incomplete npx installation is missing Ajv meta-schema files. + +- [#1734](https://github.com/millionco/react-doctor/pull/1734) [`025d69d`](https://github.com/millionco/react-doctor/commit/025d69d701581092632caa87ea59e5a719094ab9) Thanks [@skoshx](https://github.com/skoshx)! - Fix `js-set-map-lookups` false positives for substring checks on values returned by the global `String` constructor. + +- [#1725](https://github.com/millionco/react-doctor/pull/1725) [`0f59a3b`](https://github.com/millionco/react-doctor/commit/0f59a3b84dd5233f6bcf5e4a621da6699c432405) Thanks [@aidenybai](https://github.com/aidenybai)! - Run `test-noise` rules in ambiguous product-named directories such as `tools`, `demo`, and `migrations` when they are below a recognized application source root. Explicit test surfaces and root-level tooling or example directories remain excluded. + +- [#1688](https://github.com/millionco/react-doctor/pull/1688) [`72a4f46`](https://github.com/millionco/react-doctor/commit/72a4f4684cca91b823162c226c6b310c6321462b) Thanks [@skoshx](https://github.com/skoshx)! - Make generated GitHub workflows explain how to pin the action to an immutable commit SHA. + +- [#1663](https://github.com/millionco/react-doctor/pull/1663) [`2b0f06e`](https://github.com/millionco/react-doctor/commit/2b0f06ec70943f083d8893f8a1b989eba2ae40c6) Thanks [@aidenybai](https://github.com/aidenybai)! - Improve repeated effect analysis and deeply nested JSX performance, preserve derived-state detection through transparent TypeScript wrappers, and upgrade Oxc parser and linter dependencies. + +- [#1650](https://github.com/millionco/react-doctor/pull/1650) [`0b670aa`](https://github.com/millionco/react-doctor/commit/0b670aa6f58c7458f69feca132db9ae33146b891) Thanks [@aidenybai](https://github.com/aidenybai)! - Run installed Claude Code and Cursor hooks once at the end of an agent turn, include untracked files in the changed-file scan, and migrate existing per-tool React Doctor hooks automatically. + +- [#1746](https://github.com/millionco/react-doctor/pull/1746) [`ca30808`](https://github.com/millionco/react-doctor/commit/ca30808bd3581ca5a4ea0b85dc405b149baf741a) Thanks [@aidenybai](https://github.com/aidenybai)! - Accept source file paths as positional CLI arguments. + +- [#1624](https://github.com/millionco/react-doctor/pull/1624) [`8c2f03a`](https://github.com/millionco/react-doctor/commit/8c2f03aea9885f24da8f2002e85a32ac186bf5bf) Thanks [@aidenybai](https://github.com/aidenybai)! - Make React cleanup a first-class part of React Doctor with diagnostics for complex React functions and repeated JSX composition. Keep whole-project unused file, export, type, dependency, and import-cycle analysis as explicit opt-in rules while removing the separate Deslop packages, experimental language server, and IDE extensions. + +- [#1653](https://github.com/millionco/react-doctor/pull/1653) [`1971506`](https://github.com/millionco/react-doctor/commit/1971506440b74715e8115e321286112b895ed0a5) Thanks [@aidenybai](https://github.com/aidenybai)! - Add an interactive URL scan and `/performance` skill that record Chrome DevTools traces, flash live component render outlines, and return agent-readable React performance context. + +- [#1654](https://github.com/millionco/react-doctor/pull/1654) [`6416370`](https://github.com/millionco/react-doctor/commit/6416370836deaa0a09189343a8579fb3f5d13494) Thanks [@aidenybai](https://github.com/aidenybai)! - Add component-composition and correctness rules for shadcn, Radix UI, Base UI, React Aria, TanStack Table, and TanStack Virtual behind six new project capabilities (`shadcn` from `components.json`; the rest from their package dependencies). Dialog surfaces that render no title part and carry no accessible name are reported across all three libraries (shadcn DialogContent/SheetContent/AlertDialogContent/DrawerContent, Radix Dialog.Content and AlertDialog.Content, Base UI Dialog.Popup and AlertDialog.Popup). Icon-sized shadcn Buttons with no accessible name, shadcn FormItem fields wrapping a FormControl without a FormLabel, and Base UI Field.Root controls without a Field.Label are reported as unlabeled. Raw Input, Textarea, and Button controls placed directly inside shadcn InputGroup are reported in favor of its InputGroupInput, InputGroupTextarea, and InputGroupAddon parts, and presence-only `data-[selected]:` / `data-[disabled]:` Tailwind variants on command items are reported because cmdk renders both attributes as `"true"` or `"false"`. TanStack Form submit handlers that call the form's `handleSubmit` without `event.preventDefault()` are reported because the browser still performs a native full-page submission. Tabs triggers provably inside the root without the list part are reported for shadcn, Radix, and Base UI; the existing `shadcn-tabs-trigger-requires-list` rule is now enabled by default for shadcn projects through the capability gate and no longer risks false positives on extracted trigger subcomponents. React Aria Dialogs without a Heading or aria-label are reported as unnamed. TanStack Table `data`/`columns` options that provably get a new array identity every render (inline literals, render-scoped const arrays, fresh `?? []` fallbacks, inline `.filter()`/`.map()` transforms) are reported for rebuilding row and column models each render and looping auto-reset features, and elements measured by TanStack Virtual's `measureElement` without a `data-index` attribute are reported because the virtualizer drops the measurement. + +- [#1743](https://github.com/millionco/react-doctor/pull/1743) [`6f3dd03`](https://github.com/millionco/react-doctor/commit/6f3dd033d5697b73b7c47eb0d47a92795cedf12b) Thanks [@aidenybai](https://github.com/aidenybai)! - Normalize Oxlint file URLs before applying ignore patterns and writing report-relative diagnostic paths. + +- Updated dependencies [[`ffc2d14`](https://github.com/millionco/react-doctor/commit/ffc2d142545167107b11908f004d764ac4e31399), [`f7efb7d`](https://github.com/millionco/react-doctor/commit/f7efb7d1c4fc564fa647a0dc26c48867da9166c9), [`05ef989`](https://github.com/millionco/react-doctor/commit/05ef98926de787b01e817c8853101d6c31e2071a), [`a04b933`](https://github.com/millionco/react-doctor/commit/a04b933c027f6addf4161ba0df1c11eb8922b879), [`adc3a91`](https://github.com/millionco/react-doctor/commit/adc3a9129190315263a5fa92bda7ea3e3e2ba94a), [`2c4560f`](https://github.com/millionco/react-doctor/commit/2c4560fc0abbf70f1574fe847402d320347d061e), [`e1d4c51`](https://github.com/millionco/react-doctor/commit/e1d4c51abfd9d15ec96f5001259c3e8f332f7d50), [`905607f`](https://github.com/millionco/react-doctor/commit/905607f7fc2240304cbad5f41d3ad496eab06b17), [`17eeeb5`](https://github.com/millionco/react-doctor/commit/17eeeb5367177e6a3ba814ca8d107d009addc9dc), [`afa1780`](https://github.com/millionco/react-doctor/commit/afa1780254bfd72175e6d0025841560582d32ad1), [`025d69d`](https://github.com/millionco/react-doctor/commit/025d69d701581092632caa87ea59e5a719094ab9), [`0f59a3b`](https://github.com/millionco/react-doctor/commit/0f59a3b84dd5233f6bcf5e4a621da6699c432405), [`5bc88ae`](https://github.com/millionco/react-doctor/commit/5bc88ae6a0cd7518ffa8c6348f9176868d00ea77), [`4bf7aff`](https://github.com/millionco/react-doctor/commit/4bf7aff4398383adb6b3dace48f72050dfd195a6), [`bd08406`](https://github.com/millionco/react-doctor/commit/bd08406381618785181aedf8bee956047ad107d3), [`2b0f06e`](https://github.com/millionco/react-doctor/commit/2b0f06ec70943f083d8893f8a1b989eba2ae40c6), [`8c2f03a`](https://github.com/millionco/react-doctor/commit/8c2f03aea9885f24da8f2002e85a32ac186bf5bf), [`6416370`](https://github.com/millionco/react-doctor/commit/6416370836deaa0a09189343a8579fb3f5d13494), [`28d4343`](https://github.com/millionco/react-doctor/commit/28d4343e4d90a8d80c0fdb5eac0173bdd8826866)]: + - oxlint-plugin-react-doctor@0.9.13 + ## 0.9.12 ### Patch Changes diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index 3f5750b8a4..6dd74bada4 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -1,6 +1,6 @@ { "name": "react-doctor", - "version": "0.9.12", + "version": "0.9.13", "description": "Your agent writes bad React. This catches it", "keywords": [ "accessibility",