diff --git a/.changeset/quiet-omitted-state-setters.md b/.changeset/quiet-omitted-state-setters.md new file mode 100644 index 0000000000..8e30daf1b3 --- /dev/null +++ b/.changeset/quiet-omitted-state-setters.md @@ -0,0 +1,17 @@ +--- +"react-doctor": patch +"eslint-plugin-react-doctor": patch +"oxlint-plugin-react-doctor": patch +--- + +Allow immutable value-only useState tuples with omitted setters while preserving value-pattern and setter-naming checks. + +Keep runtime loader and provider exposure unsupported when TypeScript-only declarations erase their apparent local bindings. + +Recognize erased type-only import equals bindings while preserving genuine local runtime values merged with interfaces. + +Recognize empty and type-only namespaces without treating instantiated namespace values as native globals. + +Recognize bounded numeric data indexing with closed callers and native window listeners while preserving runtime loader, provider mutation and callable extraction checks. + +Reuse program-wide provider and numeric helper facts, and reject provider replacements through legacy accessor methods. diff --git a/.changeset/semantic-runtime-reference-resolution.md b/.changeset/semantic-runtime-reference-resolution.md new file mode 100644 index 0000000000..b26de3ebac --- /dev/null +++ b/.changeset/semantic-runtime-reference-resolution.md @@ -0,0 +1,7 @@ +--- +"oxlint-plugin-react-doctor": patch +"eslint-plugin-react-doctor": patch +"react-doctor": patch +--- + +Preserve runtime value references and writes when erased TypeScript declarations share their names, so provider exposure cannot bypass diagnostic proofs. diff --git a/packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts b/packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts index 47ef81c548..a0664e8b6f 100644 --- a/packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts +++ b/packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts @@ -1,8 +1,14 @@ import { Snapshot } from "@vercel/sandbox"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { deleteVercelSnapshotBeforeDeadline } from "../src/utils/delete-vercel-snapshot-before-deadline.js"; const CONTROL_PLANE_TEST_TIMEOUT_MS = 5; -afterEach(() => vi.restoreAllMocks()); +beforeEach(() => { + vi.useFakeTimers({ toFake: ["performance", "setTimeout", "clearTimeout"] }); +}); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); describe("deleteVercelSnapshotBeforeDeadline", () => { it("recovers snapshots when the create response was lost", async () => { @@ -32,13 +38,16 @@ describe("deleteVercelSnapshotBeforeDeadline", () => { snapshotId: { value: "snapshot-name" }, delete: { value: () => new Promise(() => undefined) }, }); - await expect( - deleteVercelSnapshotBeforeDeadline({ - credentials: {}, - snapshotName: "builder", - snapshot, - deadlineMilliseconds: globalThis.performance.now() + CONTROL_PLANE_TEST_TIMEOUT_MS, - }), - ).rejects.toThrow("Timed out deleting Vercel snapshot snapshot-name"); + const deletion = deleteVercelSnapshotBeforeDeadline({ + credentials: {}, + snapshotName: "builder", + snapshot, + deadlineMilliseconds: globalThis.performance.now() + CONTROL_PLANE_TEST_TIMEOUT_MS, + }); + const deletionAssertion = expect(deletion).rejects.toThrow( + "Timed out deleting Vercel snapshot snapshot-name", + ); + await vi.advanceTimersByTimeAsync(CONTROL_PLANE_TEST_TIMEOUT_MS); + await deletionAssertion; }); }); diff --git a/packages/fuzz/corpus/regressions/hook-use-state--actual-mismatched-setter.tsx b/packages/fuzz/corpus/regressions/hook-use-state--actual-mismatched-setter.tsx new file mode 100644 index 0000000000..09af9b8e08 --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--actual-mismatched-setter.tsx @@ -0,0 +1,10 @@ +// rule: hook-use-state +// verdict: fail +// weakness: library-idiom +// source: synthetic actual setter naming remains enforced +import { useState } from "react"; + +export const useEditableMarker = () => { + const [editableMarker, replaceMarker] = useState("initial"); + return { editableMarker, replaceMarker }; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--closed-numeric-data.tsx b/packages/fuzz/corpus/regressions/hook-use-state--closed-numeric-data.tsx new file mode 100644 index 0000000000..69ef2999b9 --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--closed-numeric-data.tsx @@ -0,0 +1,26 @@ +// rule: hook-use-state +// weakness: dynamic-computed +// source: independently authored numeric data and listener example +// verdict: pass +import { useEffect, useState } from "react"; + +export const NumericPanel = ({ rawIndex }) => { + const [storedKey] = useState(7); + const getIndex = (value) => Number(value); + const updateValues = (position) => { + const values = [3, 7]; + values[position] = 8; + const previousValue = values[position]; + values.reverse(); + return values.indexOf(previousValue); + }; + const amount = updateValues(getIndex(rawIndex)); + useEffect(() => { + const listener = () => { + void amount; + }; + window.addEventListener("sample", listener); + return () => window.removeEventListener("sample", listener); + }, [amount]); + return {storedKey + amount}; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--erased-loader-provider.tsx b/packages/fuzz/corpus/regressions/hook-use-state--erased-loader-provider.tsx new file mode 100644 index 0000000000..d14575417b --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--erased-loader-provider.tsx @@ -0,0 +1,13 @@ +// rule: hook-use-state +// verdict: fail +// weakness: alias-guard +// source: synthetic erased TypeScript loader binding +import { useState } from "react"; + +declare const require: (source: string) => { useState: unknown }; +require("react").useState = () => [7, () => {}]; + +export const Panel = () => { + const [seededTotal] = useState(7); + return
{seededTotal}
; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--erased-runtime-provider-reference.tsx b/packages/fuzz/corpus/regressions/hook-use-state--erased-runtime-provider-reference.tsx new file mode 100644 index 0000000000..60809c839e --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--erased-runtime-provider-reference.tsx @@ -0,0 +1,15 @@ +// rule: hook-use-state +// weakness: alias-guard +// source: independently-authored-public-synthetic +// verdict: fail +import { useState } from "react"; +import { mutate } from "./external"; +function expose() { + interface useState {} + mutate(useState); +} +expose(); +export const Panel = () => { + const [value] = useState(0); + return
{value}
; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--local-loader-interface.tsx b/packages/fuzz/corpus/regressions/hook-use-state--local-loader-interface.tsx new file mode 100644 index 0000000000..05aa1c60cf --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--local-loader-interface.tsx @@ -0,0 +1,14 @@ +// rule: hook-use-state +// verdict: pass +// weakness: alias-guard +// source: synthetic local provider binding merged with an interface +import { useState } from "react"; + +const require = () => ({ useState: () => [7, () => {}] }); +interface require {} +require("react").useState = () => [9, () => {}]; + +export const Panel = () => { + const [seededTotal] = useState(7); + return
{seededTotal}
; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--mutated-provider-accessor.tsx b/packages/fuzz/corpus/regressions/hook-use-state--mutated-provider-accessor.tsx new file mode 100644 index 0000000000..7fab61f330 --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--mutated-provider-accessor.tsx @@ -0,0 +1,13 @@ +// rule: hook-use-state +// weakness: alias-guard +// source: independently authored React provider mutation control +// verdict: fail +import React from "react"; + +const replacementState = () => [7]; +React.__defineGetter__("useState", () => replacementState); + +export const MutationPanel = () => { + const [stored] = React.useState(7); + return {stored}; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--omitted-canonical-require.tsx b/packages/fuzz/corpus/regressions/hook-use-state--omitted-canonical-require.tsx new file mode 100644 index 0000000000..0d061a473a --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--omitted-canonical-require.tsx @@ -0,0 +1,10 @@ +// rule: hook-use-state +// verdict: pass +// weakness: library-idiom +// source: synthetic direct CommonJS provider +const React = require("react"); + +export const useRetainedKey = () => { + const [retainedKey] = React.useState("initial"); + return retainedKey; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--omitted-snapshot.tsx b/packages/fuzz/corpus/regressions/hook-use-state--omitted-snapshot.tsx new file mode 100644 index 0000000000..9b747b427a --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--omitted-snapshot.tsx @@ -0,0 +1,11 @@ +// rule: hook-use-state +// verdict: pass +// weakness: library-idiom +// source: synthetic immutable value-only tuple +import { useState } from "react"; + +export const useRetainedMarker = (initialMarker: string) => { + const [retainedMarker] = useState(initialMarker); + const [readRetainedMarker] = useState(() => () => retainedMarker); + return readRetainedMarker; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--runtime-namespace-provider.tsx b/packages/fuzz/corpus/regressions/hook-use-state--runtime-namespace-provider.tsx new file mode 100644 index 0000000000..fe3c51df9a --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--runtime-namespace-provider.tsx @@ -0,0 +1,16 @@ +// rule: hook-use-state +// verdict: pass +// weakness: alias-guard +// source: synthetic namespace provider binding +function require() { + return { useState: customHook }; +} +namespace require { + export interface Loader {} +} +require("react").useState = customHook; +import { useState } from "react"; +export const Panel = () => { + const [value] = useState(0); + return
{value}
; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--type-import-equals-provider.tsx b/packages/fuzz/corpus/regressions/hook-use-state--type-import-equals-provider.tsx new file mode 100644 index 0000000000..b5179f2f90 --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--type-import-equals-provider.tsx @@ -0,0 +1,13 @@ +// rule: hook-use-state +// verdict: fail +// weakness: alias-guard +// source: synthetic type-only import equals provider binding +import { useState } from "react"; +import type require = require("./provider-types"); + +require("react").useState = () => [7, () => {}]; + +export const Panel = () => { + const [seededTotal] = useState(7); + return
{seededTotal}
; +}; diff --git a/packages/fuzz/corpus/regressions/hook-use-state--type-only-namespace-provider.tsx b/packages/fuzz/corpus/regressions/hook-use-state--type-only-namespace-provider.tsx new file mode 100644 index 0000000000..01e3581d9b --- /dev/null +++ b/packages/fuzz/corpus/regressions/hook-use-state--type-only-namespace-provider.tsx @@ -0,0 +1,15 @@ +// rule: hook-use-state +// verdict: fail +// weakness: alias-guard +// source: synthetic namespace provider binding +namespace require { + export namespace Shape { + export interface Loader {} + } +} +require("react").useState = customHook; +import { useState } from "react"; +export const Panel = () => { + const [value] = useState(0); + return
{value}
; +}; diff --git a/packages/fuzz/src/snippet-pools.ts b/packages/fuzz/src/snippet-pools.ts index ba286100e6..eb8775f6f5 100644 --- a/packages/fuzz/src/snippet-pools.ts +++ b/packages/fuzz/src/snippet-pools.ts @@ -170,6 +170,7 @@ export const EFFECT_SNIPPET_POOL = [ // State — lazy initializers (incl. SSR-hazardous localStorage/matchMedia), // toggles, loading triples, prop mirrors, reducers, ref-sync. export const STATE_SNIPPET_POOL = [ + `{ const [fuzzRetainedMarker] = useState(value); const [fuzzRetainedReader] = useState(() => () => fuzzRetainedMarker); const fuzzRetainedReaderResult = fuzzRetainedReader(); }`, `const fuzzValtioState = fuzzValtioProxy({ count: 0 }); const fuzzValtioSnapshot = useFuzzValtioSnapshot(fuzzValtioState); const handleFuzzValtioRead = () => console.log(fuzzValtioSnapshot.count); const fuzzValtioButton = ;`, `const FuzzArrayConsumer = memo(() => null); const FuzzArrayHost = () => ;`, `const FuzzEffectEventHost = ({ delay, onSearch }) => { useEffect(() => { const timeoutId = setTimeout(() => onSearch("done"), delay); return () => clearTimeout(timeoutId); }, [delay, onSearch]); return null; }; function FuzzTransitionHost() { const [isLoading, setIsLoading] = useState(false); const toggleLoading = () => setIsLoading(true); return ; } function FuzzMemoEarlyReturnHost({ loading }) { const content = useMemo(() => , []); if (loading) return null; return
{content}
; }`, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts index 0f45ea5560..eb6078bbda 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts @@ -319,3 +319,13 @@ export const NAVIGATION_RECEIVER_NAMES = new Set([ export const NON_HOOK_REGISTRATION_APIS: ReadonlyMap> = new Map([ ["react_ujs", new Set(["useContext", "useContexts"])], ]); + +export const VALUE_ONLY_STATE_TUPLE_LENGTH = 1; +export const CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT = 1; + +export const REACT_PROVIDER_MUTATION_CAPABILITY_NAMES = new Set([ + "__defineGetter__", + "__defineSetter__", + "__proto__", + "prototype", +]); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts index 16924599d5..3b5137ccde 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts @@ -81,3 +81,11 @@ export const MAX_MEMO_COMPARATOR_SYMBOLIC_ATOM_COUNT = 8; export const LARGE_TEXT_OPTIMIZATION_THRESHOLD_CHARS = 1000; export const STATIC_STRING_VALUE_LIMIT = 32; export const INERT_REF_ONE_SHOT_TIMER_MAX_DELAY_MS = 300; + +export const NUMERIC_DATA_SMALL_READ_COUNT = 100; +export const NUMERIC_DATA_LARGE_READ_COUNT = 500; +export const NUMERIC_DATA_MAXIMUM_VISIT_SCALING_MULTIPLIER = 8; + +export const LOCAL_REACT_STATE_SMALL_CALL_COUNT = 16; +export const LOCAL_REACT_STATE_LARGE_CALL_COUNT = 64; +export const LOCAL_REACT_STATE_MAXIMUM_LOOKUP_SCALING_MULTIPLIER = 6; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.numeric-data-provider.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.numeric-data-provider.test.ts new file mode 100644 index 0000000000..e9274812c1 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.numeric-data-provider.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import { hasRefNotificationReactCall } from "../state-and-effects/utils/has-ref-notification-react-call.js"; +import { hookUseState } from "./hook-use-state.js"; + +interface NumericDataProviderCase { + readonly name: string; + readonly code: string; + readonly expectedCount: number; +} + +const cases: NumericDataProviderCase[] = [ + ...["__defineGetter__", "__defineSetter__"].map((method) => ({ + name: `rejects React provider replacement through ${method}`, + code: `import React from "react";React.${method}("useState",()=>customState);export const Panel=()=>{const [stored]=React.useState(7);return {stored};};`, + expectedCount: 1, + })), + { + name: "ordinary numeric data", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "local numeric function", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const getIndex=(target)=>Number(target);const index=getIndex(input);const values=[3,7];const amount=values[index]+1;return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "closed numeric parameter callers", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const assign=(position)=>{const values=[3,7];values[position]=8;return values[position]+1;};const amount=assign(Number(input));return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "fresh array indexOf data alias", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);values[index]=8;const previous=values[index];values.reverse();const amount=values.indexOf(previous);return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "subtraction numeric key", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const amount=values[values.length-1]+1;return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "unknown function extraction", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const getKey=()=>input;const execute=(()=>{})[getKey()];execute("return null");return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "numeric function extraction", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const handlers=[()=>null];const selected=handlers[Number(input)];selected();return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "numeric direct callee", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const handlers=[()=>null];handlers[Number(input)]();return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "unused extracted capability", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const handlers=[()=>null];const selected=handlers[Number(input)];return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "const constructor key", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const key="constructor";const execute=(()=>{})[key];execute("return null");return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "numeric object write", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const record={};record[Number(input)]=8;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "typed open parameter", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const read=(position:number)=>values[position]+1;publish(read);const amount=0;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "escaped parameter function", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const assign=(position)=>{const values=[3,7];values[position]=8;};publish(assign);const amount=0;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "shadow Number string key", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const Number=()=>"constructor";const execute=(()=>{})[Number()];execute("return null");const amount=0;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "array escaped before indexOf", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const previous=values[index];install(values);const amount=values.indexOf(previous);return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "hoisted array escape", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const previous=values[index];const amount=values.indexOf(previous);function expose(){install(values)}return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "indexOf in repeat loop", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const previous=values[index];for(const item of input){values.indexOf(previous)}const amount=0;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "dynamic React loader", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const start=async()=>{const provider=await import("react");provider.default.useState=customHook;return Panel(input)};return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "native imported loader", + code: 'import {useState} from "react";import {createRequire} from "node:module";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const loader=createRequire(import.meta.url);loader("react").useState=customHook;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "process loader", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;process.mainModule.require("react").useState=customHook;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "require loader", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;require("react").useState=customHook;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "provider namespace replacement", + code: 'import {useState} from "react";import * as Provider from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;Provider.useState=customHook;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "direct window listeners", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const listener=()=>consume(amount);window.addEventListener("custom",listener);window.removeEventListener("custom",listener);return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "window capture boolean", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const listener=()=>consume(amount);window.addEventListener("custom",listener,true);window.removeEventListener("custom",listener,true);return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "computed window method", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const listener=()=>null;window["addEventListener"]("custom",listener);return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "optional window method", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const listener=()=>null;window.addEventListener?.("custom",listener);return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "dynamic window event", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const listener=()=>null;window.addEventListener(input,listener);return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "window options object", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const listener=()=>null;window.addEventListener("custom",listener,{capture:true});return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "window receiver escape", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;install(window);return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "window method replacement", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const listener=()=>null;window.addEventListener=install;window.addEventListener("custom",listener);return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "erased namespace and JSX import coexist", + code: 'import {Fragment,type ReactNode,useState} from "react";import * as React from "react";export const Panel:React.FC<{input:number}>=({input})=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const label:ReactNode=storedKey+amount;return {label}};', + expectedCount: 0, + }, + { + name: "visible global Number replacement", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;Number=customNumber;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "resolved Array prototype replacement", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const prototypeKey="prototype";Array[prototypeKey].indexOf=customIndexOf;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "inline exported parameter owner", + code: 'import {useState} from "react";export const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "exported function parameter owner", + code: 'import {useState} from "react";export function transport(position){const values=[3,7];values[position]=8;return values[position]+1}export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "default export parameter owner", + code: 'import {useState} from "react";const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};export default transport;export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "specifier export parameter owner", + code: 'import {useState} from "react";const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};export {transport as readValue};export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "passed callback parameter owner", + code: 'import {useState} from "react";const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};publish(transport);export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "JSX parameter owner", + code: 'import {useState} from "react";const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};const exposed=;export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "member assignment parameter owner", + code: 'import {useState} from "react";const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};target.read=transport;export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "returned parameter owner", + code: 'import {useState} from "react";const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};const escape=()=>transport;export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "before initialization call", + code: 'import {useState} from "react";const first=transport(Number(input));const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};export const Panel=(input)=>{const [storedKey]=useState(7);const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "closed local initialized before calls", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const transport=(position)=>{const values=[3,7];values[position]=8;return values[position]+1};const amount=transport(Number(input));return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "hoisted numeric function captures later const", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=getIndex();const captured=Number(input);function getIndex(){return captured}const amount=values[index]+1;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "numeric function captures earlier const", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const captured=Number(input);const getIndex=()=>captured;const index=getIndex();const amount=values[index]+1;return {storedKey+amount}};', + expectedCount: 0, + }, + { + name: "exact independent exported two-parameter capability boundary", + code: 'import { useState } from "react";\n\nexport const transport = (readKey, writeKey) => {\n const slots = [];\n slots[writeKey] = capability => capability("return import(\'react\')")();\n const selected = (() => {})[readKey];\n return slots.indexOf(selected);\n};\n\nexport const Panel = input => {\n const [storedKey] = useState(7);\n const amount = transport(Number(input), Number(input));\n return {storedKey + amount};\n};\n', + expectedCount: 1, + }, + { + name: "exact independent global Number write boundary", + code: 'import { useState } from "react";\n\nglobal.Number = value => value;\n\nexport const Panel = () => {\n const slots = [];\n slots[Number("indexOf")] = capability => capability("return import(\'react\')")();\n const selected = (() => {})[Number("constructor")];\n const amount = slots.indexOf(selected);\n const [storedKey] = useState(7);\n return {storedKey + amount};\n};\n', + expectedCount: 1, + }, + { + name: "global Number property replacement", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;global.Number=customNumber;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "aliased global Number replacement", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const host=global;host.Number=customNumber;return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "global object escape", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;install(global);return {storedKey+amount}};', + expectedCount: 1, + }, + { + name: "unrelated shadowed global value", + code: 'import {useState} from "react";export const Panel=(input)=>{const [storedKey]=useState(7);const values=[3,7];const index=Number(input);const amount=values[index]+1;const global={Number:()=>null};global.Number();return {storedKey+amount}};', + expectedCount: 0, + }, +]; + +describe("hook-use-state finite numeric data provider", () => { + it.each(cases)("$name", ({ code, expectedCount }) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(expectedCount); + }); + it.each([false, true])("keys optional window policy when first request is %s", (firstPolicy) => { + const certificates: boolean[] = []; + const code = + 'import {useState} from "react";const Panel=()=>{const [storedKey]=useState(7);const listener=()=>consume(storedKey);window.addEventListener("custom",listener);return {storedKey}};'; + const result = runRule( + { + ...hookUseState, + create: (context) => ({ + CallExpression: (node: EsTreeNode) => { + if ( + node.type !== "CallExpression" || + node.callee.type !== "Identifier" || + node.callee.name !== "useState" + ) + return; + for (const allowNativeWindowEventListeners of [firstPolicy, !firstPolicy, firstPolicy]) + certificates.push( + hasRefNotificationReactCall(node, "useState", context, { + allowNativeWindowEventListeners, + }), + ); + }, + }), + }, + code, + ); + expect(result.parseErrors).toEqual([]); + expect(certificates).toEqual([firstPolicy, !firstPolicy, firstPolicy]); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts new file mode 100644 index 0000000000..7cb00fd658 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.omitted-setter.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { hookUseState } from "./hook-use-state.js"; + +describe("hook-use-state omitted setters", () => { + it.each([ + [ + "constant initial number", + 'import {useState} from "react"; const [seededTotal] = useState(7);', + ], + [ + "prop snapshot", + 'import {useState} from "react"; const useSeed = (initialLabel) => {const [storedLabel] = useState(initialLabel); return storedLabel;};', + ], + [ + "eager allocation", + 'import {useState} from "react"; const [cacheHandle] = useState(new Map());', + ], + [ + "lazy allocation", + 'import {useState} from "react"; const [cacheHandle] = useState(() => new Map());', + ], + [ + "imported factory", + 'import {useState} from "react"; import {makeCache} from "./cache"; const [cacheHandle] = useState(makeCache);', + ], + [ + "returned function value", + 'import {useState} from "react"; const [fetchNext] = useState(() => () => 7);', + ], + [ + "promise value", + 'import {useState} from "react"; const [pendingResult] = useState(() => Promise.resolve(7));', + ], + ["default React import", 'import React from "react"; const [seededTotal] = React.useState(7);'], + [ + "namespace React import", + 'import * as React from "react"; const [seededTotal] = React.useState(7);', + ], + [ + "direct CommonJS provider", + 'const React = require("react"); const [seededTotal] = React.useState(7);', + ], + ["trailing comma", 'import {useState} from "react"; const [seededTotal,] = useState(7);'], + [ + "value member mutation", + 'import {useState} from "react"; const [cacheHandle] = useState(() => ({entry: 7})); cacheHandle.entry = 8;', + ], + [ + "hook metadata", + 'import {useState} from "react"; useState.description = "stable"; const [seededTotal] = useState(7);', + ], + [ + "provider metadata", + 'import React from "react"; React.description = "stable"; const [seededTotal] = React.useState(7);', + ], + [ + "member-pattern metadata", + 'import React from "react"; ({metadata: React.description} = source); const [seededTotal] = React.useState(7);', + ], + [ + "default RHS reads", + 'import React from "react"; ({callback: other = React.useState} = source); const [seededTotal] = React.useState(7);', + ], + [ + "value metadata pattern", + 'import {useState} from "react"; const [cacheHandle] = useState(() => ({})); ({entry: cacheHandle.entry} = source);', + ], + [ + "value default RHS read", + 'import {useState} from "react"; const [seededTotal] = useState(7); ({total: other = seededTotal} = source);', + ], + ])("allows %s", (_name, code) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + [ + "unresolved pattern computed key reads", + 'import React from "react"; ({[React.useState]: other} = source); const [seededTotal] = React.useState(7);', + ], + [ + "unresolved pattern value computed key read", + 'import {useState} from "react"; const [seededTotal] = useState(7); ({[seededTotal]: other} = source);', + ], + ["uppercase value", 'import {useState} from "react"; const [TOTAL] = useState(7);'], + ["underscore value", 'import {useState} from "react"; const [_total] = useState(7);'], + ["top-level default", 'import {useState} from "react"; const [seededTotal = 7] = useState();'], + ["top-level rest", 'import {useState} from "react"; const [...retainedTuple] = useState(7);'], + ["empty tuple", 'import {useState} from "react"; const [] = useState(7);'], + [ + "object tuple binding", + 'import {useState} from "react"; const {0: seededTotal} = useState(7);', + ], + [ + "extra tuple slot", + 'import {useState} from "react"; const [seededTotal, setSeededTotal, extra] = useState(7);', + ], + [ + "wrong actual setter", + 'import {useState} from "react"; const [seededTotal, updateTotal] = useState(7);', + ], + [ + "underscore actual setter", + 'import {useState} from "react"; const [seededTotal, _setSeededTotal] = useState(7);', + ], + ["mutable declaration", 'import {useState} from "react"; let [seededTotal] = useState(7);'], + [ + "actual value reassignment", + 'import {useState} from "react"; const [seededTotal] = useState(7); seededTotal = 8;', + ], + [ + "pattern binding reassignment", + 'import {useState} from "react"; const [seededTotal] = useState(7); ({total: seededTotal} = source);', + ], + [ + "default pattern binding reassignment", + 'import {useState} from "react"; const [seededTotal] = useState(7); ({total: seededTotal = 8} = source);', + ], + [ + "update binding", + 'import {useState} from "react"; const [seededTotal] = useState(7); seededTotal++;', + ], + [ + "for-of binding", + 'import {useState} from "react"; const [seededTotal] = useState(7); for (seededTotal of source) {}', + ], + [ + "shadowed hook", + 'import {useState} from "react"; const useSeed = (useState) => {const [seededTotal] = useState(7); return seededTotal;};', + ], + ["local hook spelling", "const useState = () => [7]; const [seededTotal] = useState(7);"], + ["unbound hook spelling", "const [seededTotal] = useState(7);"], + [ + "custom React provider", + "const React = {useState: () => [7]}; const [seededTotal] = React.useState(7);", + ], + [ + "type-only declaration", + 'import type {useState} from "react"; const [seededTotal] = useState(7);', + ], + [ + "type-only specifier", + 'import {type useState} from "react"; const [seededTotal] = useState(7);', + ], + [ + "hook binding replacement", + 'import {useState} from "react"; useState = customHook; const [seededTotal] = useState(7);', + ], + [ + "hook function escape", + 'import {useState} from "react"; install(useState); const [seededTotal] = useState(7);', + ], + [ + "namespace hook replacement", + 'import React from "react"; React.useState = customHook; const [seededTotal] = React.useState(7);', + ], + [ + "sibling provider replacement", + 'import {useState} from "react"; import React from "react"; React.useState = customHook; const [seededTotal] = useState(7);', + ], + [ + "provider escape", + 'import React from "react"; install(React); const [seededTotal] = React.useState(7);', + ], + [ + "provider method escape", + 'import React from "react"; install(React.useState); const [seededTotal] = React.useState(7);', + ], + [ + "provider Object.assign replacement", + 'import React from "react"; Object.assign(React, {useState: customHook}); const [seededTotal] = React.useState(7);', + ], + [ + "provider pattern replacement", + 'import React from "react"; ({callback: React.useState} = source); const [seededTotal] = React.useState(7);', + ], + [ + "provider default pattern replacement", + 'import React from "react"; ({callback: React.useState = customHook} = source); const [seededTotal] = React.useState(7);', + ], + [ + "provider for-of replacement", + 'import React from "react"; for (React.useState of source) {} const [seededTotal] = React.useState(7);', + ], + [ + "sibling namespace pattern replacement", + 'import {useState} from "react"; import * as Provider from "react"; ({callback: Provider.useState} = source); const [seededTotal] = useState(7);', + ], + [ + "CJS provider shadow", + 'const require = () => ({useState: () => [7]}); const React = require("react"); const [seededTotal] = React.useState(7);', + ], + [ + "CJS projected provider", + 'const React = require("react").custom; const [seededTotal] = React.useState(7);', + ], + [ + "CJS mutable provider", + 'let React = require("react"); const [seededTotal] = React.useState(7);', + ], + [ + "CJS escaped provider", + 'const React = require("react"); install(React); const [seededTotal] = React.useState(7);', + ], + [ + "CJS extra loader argument", + 'const React = require("react", options); const [seededTotal] = React.useState(7);', + ], + [ + "CJS deferred initialization", + 'const [seededTotal] = React.useState(7); const React = require("react");', + ], + [ + "hybrid CJS provider exposure", + 'import {useState} from "react"; const Provider = require("react"); Provider.useState = customHook; const [seededTotal] = useState(7);', + ], + [ + "CJS pattern replacement", + 'const React = require("react"); ({callback: React.useState} = source); const [seededTotal] = React.useState(7);', + ], + ])("retains %s", (_name, code) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const React = require("react"); with (provider) { const [seededTotal] = React.useState(7); }`, + `const React = require("react"); eval("React.useState = customHook"); const [seededTotal] = React.useState(7);`, + `require = customLoader; const React = require("react"); const [seededTotal] = React.useState(7);`, + `require.cache[require.resolve("react")] = {exports: customProvider}; const React = require("react"); const [seededTotal] = React.useState(7);`, + `const loader = require; const React = require("react"); const [seededTotal] = React.useState(7);`, + `install(require); const React = require("react"); const [seededTotal] = React.useState(7);`, + `require(moduleName); const React = require("react"); const [seededTotal] = React.useState(7);`, + `({loader: require} = loaders); const React = require("react"); const [seededTotal] = React.useState(7);`, + `for (require of loaders) {} const React = require("react"); const [seededTotal] = React.useState(7);`, + ])("keeps dynamic loader/provider scope unsupported: %s", (code) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `module.require("react").useState = customHook;`, + `module["require"]("react").useState = customHook;`, + `module.parent.require("react").useState = customHook;`, + `const Module = require("module"); Module._load("react", module).useState = customHook;`, + `const Module = require("node:module"); const loader = Module.createRequire(__filename); loader("react").useState = customHook;`, + `const {createRequire} = require("module"); const loader = createRequire(__filename); loader("react").useState = customHook;`, + `process.mainModule.require("react").useState = customHook;`, + `process.getBuiltinModule("module")._load("react", module).useState = customHook;`, + ])("keeps visible native loader exposure unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it("keeps a second imported native loader unsupported", () => { + const result = runRule( + hookUseState, + `import React from "react"; import {createRequire} from "node:module"; const loader = createRequire(import.meta.url); loader("react").useState = customHook; const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const mode = process.env.NODE_ENV;`, + `const module = {require: () => ({})}; module.require("other");`, + `const process = {mainModule: {require: () => ({})}}; process.mainModule.require("other");`, + ])("preserves harmless process metadata and unrelated shadowed loaders: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `globalThis.eval('process.mainModule.require("react").useState = customHook');`, + `Function('process.mainModule.require("react").useState = customHook')();`, + `globalThis.Function('process.mainModule.require("react").useState = customHook')();`, + `const execute = (() => {}).constructor; execute('process.mainModule.require("react").useState = customHook')();`, + `const constructorKey = "constructor"; const execute = (() => {})[constructorKey]; execute('process.mainModule.require("react").useState = customHook')();`, + `const {constructor: execute} = () => {}; execute('process.mainModule.require("react").useState = customHook')();`, + ])("keeps dynamic provider execution unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const description = handler.constructor.name;`, + `const argumentCount = handler.constructor.length;`, + ])("allows immediate readonly constructor metadata: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `handler.constructor('process.mainModule.require("react").useState = customHook')();`, + `handler.constructor.bind(null)('process.mainModule.require("react").useState = customHook')();`, + `handler.constructor.name();`, + `handler.constructor.length = customHook;`, + ])("keeps callable or writable constructor exposure unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it("preserves an unrelated shadowed Function binding", () => { + const result = runRule( + hookUseState, + `const React = require("react"); const Function = () => () => null; Function("metadata")(); const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `const execute = Reflect.get(() => {}, "constructor"); execute('process.mainModule.require("react").useState = customHook')();`, + `const execute = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(() => {}), "constructor").value; execute('process.mainModule.require("react").useState = customHook')();`, + `const key = "process"; globalThis[key].mainModule.require("react").useState = customHook;`, + `const key = "require"; globalThis[key]("react").useState = customHook;`, + `const key = "Function"; globalThis[key]('process.mainModule.require("react").useState = customHook')();`, + `const key = "eval"; globalThis[key]('process.mainModule.require("react").useState = customHook');`, + `const getKey = () => "constructor"; const execute = (() => {})[getKey()]; execute('process.mainModule.require("react").useState = customHook')();`, + `const entries = {load: () => null}; const execute = entries[readKey()];`, + ])("keeps reflection and unknown computed exposure unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it("keeps a dynamic module mutation before a later component invocation unsupported", () => { + const result = runRule( + hookUseState, + `import React from "react"; const Panel = () => {const [seededTotal] = React.useState(7); return seededTotal;}; const initialize = async () => {const provider = await import("react"); provider.default.useState = customHook; return Panel();};`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const Object = {getPrototypeOf: () => null}; Object.getPrototypeOf("metadata");`, + `const Reflect = {get: () => null}; Reflect.get("metadata");`, + `const entries = [7]; const description = entries[0];`, + `const key = "description"; const entries = {description: "stable"}; const description = entries[key];`, + ])("allows shadowed reflection and static primitive keys: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `({[React.useState]: other} = source);`, + `const getKey = () => "constructor"; const {[getKey()]: execute} = () => {}; execute('process.mainModule.require("react").useState = () => 7')();`, + ])("keeps unresolved computed pattern exposure unsupported: %s", (exposure) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${exposure} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `const metadataKey = "description"; ({[metadataKey]: other} = source);`, + `({"description": other = React.useState} = source);`, + ])("allows known primitive pattern keys and default reads: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `declare const require: any; require("react").useState = customHook;`, + `import type require = require("./types"); require("react").useState = customHook;`, + `namespace require {} require("react").useState=customHook;`, + `namespace require {export interface Loader {}} require("react").useState=customHook;`, + `namespace require {type Loader=unknown;} require("react").useState=customHook;`, + `namespace require {export namespace Shape {export interface Loader {}}} require("react").useState=customHook;`, + `declare function require(source: string): any; require("react").useState = customHook;`, + `interface Object {} Object.defineProperty(provider, "useState", {value: customHook});`, + `type Reflect = unknown; Reflect.set(provider, "useState", customHook);`, + `import type {Function} from "./types"; Function("replaceProvider()")();`, + `import {type Object} from "./types"; Object.defineProperty(provider, "useState", {value: customHook});`, + `import type Object = require("./types"); Object.defineProperty(provider, "useState", {value: customHook});`, + `declare const globalThis: any; globalThis.require("react").useState = customHook;`, + `declare const module: any; module.require("react").useState = customHook;`, + `declare const process: any; process.mainModule.require("react").useState = customHook;`, + ])("retains diagnostics through erased provider bindings: %s", (exposure) => { + const result = runRule( + hookUseState, + `import {useState} from "react"; ${exposure} const [seededTotal] = useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `declare const require: any;`, + `interface Object { description: string; }`, + `type Reflect = unknown;`, + `import type {Function} from "./types";`, + `const require = () => ({}); require("metadata");`, + `const require = () => ({useState: customHook}); interface require {} require("react").useState = customHook;`, + `interface require {} const require = () => ({useState: customHook}); require("react").useState = customHook;`, + `namespace require {export const provider={useState:customHook};} require.provider.useState=customHook;`, + `function require(){return {useState:customHook};} namespace require {export interface Loader {}} require("react").useState=customHook;`, + `namespace require {export const provider={useState:customHook};} namespace require {export interface Loader {}} require.provider.useState=customHook;`, + `const Object = {defineProperty() {}}; interface Object {} Object.defineProperty(provider, "useState", {value: customHook});`, + ])("allows unused erased declarations and real local loaders: %s", (metadata) => { + const result = runRule( + hookUseState, + `import {useState} from "react"; ${metadata} const [seededTotal] = useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it("allows other ordinary literal CommonJS loads", () => { + const result = runRule( + hookUseState, + `const React = require("react"); const renderer = require("react-dom"); const widget = require("./widget"); const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `React.description = "stable";`, + `({metadata: React.description} = source);`, + `({callback: other = React.useState} = source);`, + ])("allows CommonJS metadata and non-target reads: %s", (metadata) => { + const result = runRule( + hookUseState, + `const React = require("react"); ${metadata} const [seededTotal] = React.useState(7);`, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + it.each([ + `const [{item}] = useState(() => ({item: 7}));`, + `const [[item]] = useState(() => [7]);`, + ])("uses the explicit nested-value setting: %s", (declaration) => { + const code = `import {useState} from "react"; ${declaration}`; + expect(runRule(hookUseState, code).diagnostics).toHaveLength(1); + expect( + runRule(hookUseState, code, { + settings: { "react-doctor": { hookUseState: { allowDestructuredState: true } } }, + }).diagnostics, + ).toHaveLength(0); + }); + it("still rejects writes to explicitly allowed nested bindings", () => { + const result = runRule( + hookUseState, + `import {useState} from "react"; const [{item}] = useState(() => ({item: 7})); ({item} = source);`, + { settings: { "react-doctor": { hookUseState: { allowDestructuredState: true } } } }, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + it.each([ + `import {useState} from "react"; const [seededTotal,,] = useState(7);`, + `import {useState} from "react"; const [,setSeededTotal] = useState(7);`, + `import {useState as retain} from "react"; const [seededTotal] = retain(7);`, + `import {useState} from "react"; const useSeedTuple = () => useState(7);`, + ])("preserves existing matcher and hole paths: %s", (code) => { + const result = runRule(hookUseState, code); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.runtime-provider-reference.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.runtime-provider-reference.test.ts new file mode 100644 index 0000000000..84658386f8 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.runtime-provider-reference.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { hookUseState } from "./hook-use-state.js"; + +const cases = [ + { + name: "useState-unmasked", + code: 'import {useState} from "react";import {mutate} from "./external";function expose(){mutate(useState);}expose();export const Panel=()=>{const[value]=useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "useState-interface", + code: 'import {useState} from "react";import {mutate} from "./external";function expose(){interface useState {}mutate(useState);}expose();export const Panel=()=>{const[value]=useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "useState-type-alias", + code: 'import {useState} from "react";import {mutate} from "./external";function expose(){type useState=unknown;mutate(useState);}expose();export const Panel=()=>{const[value]=useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "useState-real-local-interface", + code: 'import {useState} from "react";import {mutate} from "./external";function expose(){const useState=()=>[];interface useState {}mutate(useState);}expose();export const Panel=()=>{const[value]=useState(0);return
{value}
;};', + expectedCount: 0, + }, + { + name: "React-unmasked", + code: 'import React from "react";import {mutate} from "./external";function expose(){mutate(React);}expose();export const Panel=()=>{const[value]=React.useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "React-interface", + code: 'import React from "react";import {mutate} from "./external";function expose(){interface React {}mutate(React);}expose();export const Panel=()=>{const[value]=React.useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "React-type-alias", + code: 'import React from "react";import {mutate} from "./external";function expose(){type React=unknown;mutate(React);}expose();export const Panel=()=>{const[value]=React.useState(0);return
{value}
;};', + expectedCount: 1, + }, + { + name: "React-real-local-interface", + code: 'import React from "react";import {mutate} from "./external";function expose(){const React={useState:()=>[]};interface React {}mutate(React);}expose();export const Panel=()=>{const[value]=React.useState(0);return
{value}
;};', + expectedCount: 0, + }, +]; + +describe("hook-use-state runtime provider references", () => { + it.each(cases)("$name", ({ code, expectedCount }) => { + const result = runRule(hookUseState, code, { + filename: "/tmp/rd-runtime-provider-references/view.tsx", + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(expectedCount); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.ts index 3e713abd4f..accbc11791 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-builtins/hook-use-state.ts @@ -1,9 +1,14 @@ +import { hasRefNotificationReactCall } from "../state-and-effects/utils/has-ref-notification-react-call.js"; +import { VALUE_ONLY_STATE_TUPLE_LENGTH } from "../../constants/react.js"; import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { findTransparentExpressionRoot } from "../../utils/find-transparent-expression-root.js"; +import { isBindingAssignmentTarget } from "../../utils/is-binding-assignment-target.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import { isReactFunctionCall } from "../../utils/is-react-function-call.js"; +import { isUnmodifiedLocalReactStateCall } from "../../utils/is-unmodified-local-react-state-call.js"; +import { walkAst } from "../../utils/walk-ast.js"; const REQUIRE_DESTRUCTURE_MESSAGE = "`useState` should be destructured as `[value, setValue]` so readers can see the state value and setter together."; @@ -66,11 +71,6 @@ export const hookUseState = defineRule({ id: "hook-use-state", title: "useState not destructured", severity: "warn", - // Stylistic naming rule — flags `const [count, _setCount]` (the - // unused-marker underscore convention) and `const [instance] = - // useState(() => new Foo())` (the create-once initializer pattern - // where the setter is intentionally never used). Both are - // idiomatic. Default off. defaultEnabled: false, recommendation: "Destructure `useState` as `const [thing, setThing] = useState(…)` so state reads and writes stay visible together.", @@ -102,6 +102,43 @@ export const hookUseState = defineRule({ context.report({ node: parent, message: REQUIRE_DESTRUCTURE_MESSAGE }); return; } + if (idPattern.elements.length === VALUE_ONLY_STATE_TUPLE_LENGTH) { + const valueElement = idPattern.elements[0]; + const hasAllowedValue = Boolean( + valueElement && + ((isNodeOfType(valueElement, "Identifier") && + splitLeadingLowercase(valueElement.name)) || + (allowDestructuredState && isDestructurePattern(valueElement))), + ); + let hasBindingWrite = false; + walkAst(idPattern, (child) => { + if (!isNodeOfType(child, "Identifier")) return; + const symbol = context.scopes.symbolFor(child); + if (symbol?.bindingIdentifier !== child || symbol.declarationNode !== parent) return; + if ( + symbol.references.some( + (reference) => + reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier), + ) + ) { + hasBindingWrite = true; + } + }); + if ( + hasAllowedValue && + !hasBindingWrite && + isNodeOfType(parent.parent, "VariableDeclaration") && + parent.parent.kind === "const" && + (isUnmodifiedLocalReactStateCall(node, context.scopes) || + (hasRefNotificationReactCall(node, "useState", context, { + allowNativeWindowEventListeners: true, + }) && + isUnmodifiedLocalReactStateCall(node, context.scopes, { + allowNumericDataAccess: true, + }))) + ) + return; + } if (idPattern.elements.length !== 2) { context.report({ node: idPattern, message: REQUIRE_DESTRUCTURE_MESSAGE }); return; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts index 94c1049865..5ab7cc6bfe 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/effect-needs-cleanup.performance.test.ts @@ -1,12 +1,25 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { effectNeedsCleanup } from "./effect-needs-cleanup.js"; const SMALL_HANDLER_COUNT = 500; const LARGE_HANDLER_COUNT = 2500; -const MEASUREMENT_SAMPLE_COUNT = 7; const MAXIMUM_SCALING_MULTIPLIER = 15; +const traversalCounter = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/walk-ast.js", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + walkAst: ((node, visitor) => + originalModule.walkAst(node, (candidate) => { + traversalCounter(); + return visitor(candidate); + })) satisfies typeof originalModule.walkAst, + }; +}); + const buildRetainedHandlersSource = ( handlerCount: number, buildRegistration: (handlerIndex: number) => string, @@ -23,20 +36,16 @@ const buildRetainedHandlersSource = ( return `const Component = () => {\n${handlers}\nreturn <>${buttons};\n};`; }; -const measureDuration = ( +const measureTraversalWork = ( handlerCount: number, buildRegistration: (handlerIndex: number) => string, ): number => { const source = buildRetainedHandlersSource(handlerCount, buildRegistration); - const sampleDurations = Array.from({ length: MEASUREMENT_SAMPLE_COUNT }, () => { - const startedAt = process.hrtime.bigint(); - const result = runRule(effectNeedsCleanup, source); - expect(result.parseErrors).toEqual([]); - expect(result.diagnostics).toHaveLength(handlerCount); - return Number(process.hrtime.bigint() - startedAt); - }); - sampleDurations.sort((firstDuration, secondDuration) => firstDuration - secondDuration); - return sampleDurations[Math.floor(sampleDurations.length / 2)] ?? Number.POSITIVE_INFINITY; + traversalCounter.mockClear(); + const result = runRule(effectNeedsCleanup, source); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(handlerCount); + return traversalCounter.mock.calls.length; }; describe("effect-needs-cleanup performance", () => { @@ -52,11 +61,17 @@ describe("effect-needs-cleanup performance", () => { }, ]) { it(`scales near-linearly across retained ${performanceCase.name}`, () => { - measureDuration(SMALL_HANDLER_COUNT, performanceCase.buildRegistration); - measureDuration(LARGE_HANDLER_COUNT, performanceCase.buildRegistration); - const smallDuration = measureDuration(SMALL_HANDLER_COUNT, performanceCase.buildRegistration); - const largeDuration = measureDuration(LARGE_HANDLER_COUNT, performanceCase.buildRegistration); - expect(largeDuration).toBeLessThan(smallDuration * MAXIMUM_SCALING_MULTIPLIER); + const smallTraversalCount = measureTraversalWork( + SMALL_HANDLER_COUNT, + performanceCase.buildRegistration, + ); + const largeTraversalCount = measureTraversalWork( + LARGE_HANDLER_COUNT, + performanceCase.buildRegistration, + ); + expect(smallTraversalCount).toBeGreaterThan(SMALL_HANDLER_COUNT); + expect(largeTraversalCount).toBeGreaterThan(smallTraversalCount); + expect(largeTraversalCount).toBeLessThan(smallTraversalCount * MAXIMUM_SCALING_MULTIPLIER); }); } }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-boolean-toggle-without-functional-update.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-boolean-toggle-without-functional-update.performance.test.ts index c22b14689a..c46e431d81 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-boolean-toggle-without-functional-update.performance.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-boolean-toggle-without-functional-update.performance.test.ts @@ -1,48 +1,48 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { noBooleanToggleWithoutFunctionalUpdate } from "./no-boolean-toggle-without-functional-update.js"; const SMALL_SETTER_COUNT = 2_000; const LARGE_SETTER_COUNT = 10_000; -const MEASUREMENT_SAMPLE_COUNT = 5; -const MAXIMUM_SCALING_MULTIPLIER = 18; +const INDEPENDENT_FUNCTION_COUNT = 2; +const asyncFunctionScanCounter = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/walk-ast.js", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + walkAst: ((node, visitor) => { + if (node.type === "ArrowFunctionExpression" && "async" in node && node.async) + asyncFunctionScanCounter(); + return originalModule.walkAst(node, visitor); + }) satisfies typeof originalModule.walkAst, + }; +}); const buildAwaitedTogglesSource = (setterCount: number): string => `const C=()=>{const[open,setOpen]=useState(false);const run=async()=>{await load();${"setOpen(!open);".repeat(setterCount)}}}`; -const measureDuration = (source: string, setterCount: number): number => { - const startedAt = process.hrtime.bigint(); +const measureFunctionScans = (source: string, setterCount: number): number => { + asyncFunctionScanCounter.mockClear(); const result = runRule(noBooleanToggleWithoutFunctionalUpdate, source); - const duration = Number(process.hrtime.bigint() - startedAt); expect(result.parseErrors).toEqual([]); expect(result.diagnostics).toHaveLength(setterCount); - return duration; -}; - -const medianDuration = (durations: readonly number[]): number => { - const sortedDurations = [...durations].sort( - (firstDuration, secondDuration) => firstDuration - secondDuration, - ); - return sortedDurations[Math.floor(sortedDurations.length / 2)] ?? Number.POSITIVE_INFINITY; + return asyncFunctionScanCounter.mock.calls.length; }; describe("no-boolean-toggle-without-functional-update performance", () => { it("caches await reachability across many setters", () => { const smallSource = buildAwaitedTogglesSource(SMALL_SETTER_COUNT); const largeSource = buildAwaitedTogglesSource(LARGE_SETTER_COUNT); - measureDuration(smallSource, SMALL_SETTER_COUNT); - measureDuration(largeSource, LARGE_SETTER_COUNT); - const smallDurations: number[] = []; - const largeDurations: number[] = []; - // Interleaving keeps both sizes under the same runner load, and the median - // (unlike the minimum) cannot be won by a single quiet window that only a - // short run is brief enough to fit inside. - for (let sampleIndex = 0; sampleIndex < MEASUREMENT_SAMPLE_COUNT; sampleIndex += 1) { - smallDurations.push(measureDuration(smallSource, SMALL_SETTER_COUNT)); - largeDurations.push(measureDuration(largeSource, LARGE_SETTER_COUNT)); - } - expect(medianDuration(largeDurations)).toBeLessThan( - medianDuration(smallDurations) * MAXIMUM_SCALING_MULTIPLIER, + const smallFunctionScans = measureFunctionScans(smallSource, SMALL_SETTER_COUNT); + const largeFunctionScans = measureFunctionScans(largeSource, LARGE_SETTER_COUNT); + expect(smallFunctionScans).toBeGreaterThan(0); + expect(largeFunctionScans).toBe(smallFunctionScans); + const independentSource = smallSource + ";" + smallSource.replace("const C=", "const Other="); + const independentFunctionScans = measureFunctionScans( + independentSource, + SMALL_SETTER_COUNT * INDEPENDENT_FUNCTION_COUNT, ); + expect(independentFunctionScans).toBe(smallFunctionScans * INDEPENDENT_FUNCTION_COUNT); }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts index c328a49d12..93e63c999c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts @@ -1,8 +1,37 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { noMutateThenSetOrReturnSameReference } from "./no-mutate-then-set-or-return-same-reference.js"; -const MEASUREMENT_SAMPLE_COUNT = 3; +interface PairingStressFixture { + readonly source: string; + readonly pairingSiteBudget: number; +} + +interface PairingWorkMeasurement { + readonly coexistenceCalls: number; + readonly reachabilityCalls: number; +} + +const coexistenceCounter = vi.hoisted(() => vi.fn()); +const reachabilityCounter = vi.hoisted(() => vi.fn()); + +vi.mock("../../utils/nodes-can-co-execute.js", async (importOriginal) => { + const originalModule = + await importOriginal(); + return { + ...originalModule, + nodesCanCoExecute: coexistenceCounter.mockImplementation(originalModule.nodesCanCoExecute), + }; +}); + +vi.mock("../../utils/can-node-reach-node.js", async (importOriginal) => { + const originalModule = + await importOriginal(); + return { + ...originalModule, + canNodeReachNode: reachabilityCounter.mockImplementation(originalModule.canNodeReachNode), + }; +}); describe("no-mutate-then-set-or-return-same-reference", () => { it("flags setX(state.add(index)) on a state Set", () => { @@ -561,7 +590,7 @@ describe("no-mutate-then-set-or-return-same-reference", () => { }); it("amortizes mutation and same-reference pairing by control-flow block", () => { - const buildSource = (pairCount: number): string => { + const buildSource = (pairCount: number): PairingStressFixture => { const mutations = "items.push(1);".repeat(pairCount); const conditions = Array.from( { length: pairCount }, @@ -579,21 +608,48 @@ describe("no-mutate-then-set-or-return-same-reference", () => { return `(${buildBalancedResultExpression(startIndex, middleIndex)}||${buildBalancedResultExpression(middleIndex, endIndex)})`; }; const sameReferenceResults = buildBalancedResultExpression(0, resultExpressions.length); - return `const C=({flag,${conditions.join(",")}})=>{const[,setItems]=useState([]);setItems(items=>{if(flag){${mutations}}if(!flag){return ${sameReferenceResults}}return [...items]})}`; + const mutationRegions = [`if(flag){${mutations}}`]; + const returnSites = [`if(!flag){return ${sameReferenceResults}}`, "return [...items]"]; + return { + source: `const C=({flag,${conditions.join(",")}})=>{const[,setItems]=useState([]);setItems(items=>{${mutationRegions.join("")}${returnSites.join("")}})}`, + pairingSiteBudget: mutationRegions.length * returnSites.length, + }; }; - runRule(noMutateThenSetOrReturnSameReference, buildSource(200)); - const measureFastestDuration = (pairCount: number): number => { - let fastestDuration = Number.POSITIVE_INFINITY; - for (let repetition = 0; repetition < MEASUREMENT_SAMPLE_COUNT; repetition += 1) { - const startedAt = performance.now(); - const result = runRule(noMutateThenSetOrReturnSameReference, buildSource(pairCount)); - fastestDuration = Math.min(fastestDuration, performance.now() - startedAt); - expect(result.diagnostics).toHaveLength(0); - } - return fastestDuration; + const measurePairingWork = ( + source: string, + expectedDiagnosticCount: number, + ): PairingWorkMeasurement => { + coexistenceCounter.mockClear(); + reachabilityCounter.mockClear(); + const result = runRule(noMutateThenSetOrReturnSameReference, source); + expect(result.parseErrors).toHaveLength(0); + expect(result.diagnostics).toHaveLength(expectedDiagnosticCount); + return { + coexistenceCalls: coexistenceCounter.mock.calls.length, + reachabilityCalls: reachabilityCounter.mock.calls.length, + }; }; - const smallDuration = measureFastestDuration(800); - const largeDuration = measureFastestDuration(4_000); - expect(largeDuration).toBeLessThan(smallDuration * 18); + const positive = measurePairingWork( + "const C=()=>{const[,setItems]=useState([]);setItems(items=>{items.push(1);return items})}", + 1, + ); + expect(positive.coexistenceCalls).toBeGreaterThan(0); + expect(positive.reachabilityCalls).toBeGreaterThan(0); + + const smallFixture = buildSource(800); + const small = measurePairingWork(smallFixture.source, 0); + expect(small.coexistenceCalls).toBeGreaterThan(0); + expect(small.coexistenceCalls).toBeLessThanOrEqual(smallFixture.pairingSiteBudget); + expect(small.reachabilityCalls).toBeGreaterThan(0); + expect(small.reachabilityCalls).toBeLessThanOrEqual(smallFixture.pairingSiteBudget); + + const largeFixture = buildSource(4_000); + const large = measurePairingWork(largeFixture.source, 0); + expect(large.coexistenceCalls).toBeGreaterThan(0); + expect(large.coexistenceCalls).toBeLessThanOrEqual(largeFixture.pairingSiteBudget); + expect(large.reachabilityCalls).toBeGreaterThan(0); + expect(large.reachabilityCalls).toBeLessThanOrEqual(largeFixture.pairingSiteBudget); + expect(large.coexistenceCalls).toBe(small.coexistenceCalls); + expect(large.reachabilityCalls).toBe(small.reachabilityCalls); }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/has-ref-notification-react-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/has-ref-notification-react-call.ts index b702448278..4dbe7e8671 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/has-ref-notification-react-call.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/utils/has-ref-notification-react-call.ts @@ -7,9 +7,12 @@ import { findTransparentExpressionRoot } from "../../../utils/find-transparent-e import { getImportDeclarationForSymbol } from "../../../utils/get-import-declaration-for-symbol.js"; import { getImportedName } from "../../../utils/get-imported-name.js"; import { getStaticPropertyName } from "../../../utils/get-static-property-name.js"; +import { getResolvedStaticPropertyName } from "../../../utils/get-resolved-static-property-name.js"; +import { hasCompleteSymbolReferences } from "../../../utils/has-complete-symbol-references.js"; import { hasJsxRuntimePragma } from "../../../utils/has-jsx-runtime-pragma.js"; import { hasOnlyErasedTypeNameBindings } from "../../../utils/has-only-erased-type-name-bindings.js"; import { hasVisibleAmbientFunctionDeclaration } from "../../../utils/has-visible-ambient-function-declaration.js"; +import { isProvenGlobalObjectReference } from "../../../utils/is-proven-global-namespace-reference.js"; import { isNodeOfType } from "../../../utils/is-node-of-type.js"; import { isNonReferenceIdentifier } from "../../../utils/is-non-reference-identifier.js"; import { isTypeScriptTypePosition } from "../../../utils/is-typescript-type-position.js"; @@ -23,7 +26,12 @@ import { REF_NOTIFICATION_PASSIVE_OBJECT_METHODS, } from "./constants.js"; +interface RefNotificationReactCallOptions { + readonly allowNativeWindowEventListeners?: boolean; +} + interface RefNotificationReactCertificate { + readonly allowNativeWindowEventListeners: boolean; readonly sourceText: string; readonly filename: string; readonly accepted: ReadonlySet; @@ -34,6 +42,7 @@ export const hasRefNotificationReactCall = ( call: EsTreeNode, apiName: string, context: RuleContext, + options: RefNotificationReactCallOptions = {}, ): boolean => { if (!isNodeOfType(call, "CallExpression") || call.optional) return false; const callee = stripParenExpression(call.callee); @@ -55,8 +64,13 @@ export const hasRefNotificationReactCall = ( const filename = context.filename ?? "/synthetic/ref-notification.tsx"; const program = findProgramRoot(call); if (sourceText === undefined || !program) return false; + const allowNativeWindowEventListeners = options.allowNativeWindowEventListeners === true; const cached = certificates.get(scopes); - if (cached?.sourceText === sourceText && cached.filename === filename) + if ( + cached?.sourceText === sourceText && + cached.filename === filename && + cached.allowNativeWindowEventListeners === allowNativeWindowEventListeners + ) return cached.accepted.has(symbol); const imports = new Map(); const namespaceImports = new Set(); @@ -98,7 +112,9 @@ export const hasRefNotificationReactCall = ( if ( isNodeOfType(node, "MemberExpression") && ["constructor", "__proto__", "prototype", "__defineGetter__", "__defineSetter__"].includes( - getStaticPropertyName(node) ?? "", + (allowNativeWindowEventListeners + ? getResolvedStaticPropertyName(node, scopes) + : getStaticPropertyName(node)) ?? "", ) ) { unsupported = true; @@ -109,7 +125,10 @@ export const hasRefNotificationReactCall = ( isNonReferenceIdentifier(node) ) return; - if (REF_NOTIFICATION_RUNTIME_CAPABILITIES.has(node.name)) { + if ( + REF_NOTIFICATION_RUNTIME_CAPABILITIES.has(node.name) || + (allowNativeWindowEventListeners && isProvenGlobalObjectReference(node, scopes)) + ) { const capabilitySymbol = scopes.symbolFor(node); const member = node.parent; const root = member && findTransparentExpressionRoot(member); @@ -127,7 +146,41 @@ export const hasRefNotificationReactCall = ( isNodeOfType(invocation, "CallExpression") && invocation.callee === root && !invocation.optional; - if (!passiveObjectCall) { + const [eventName, listener, capture, ...extraArguments] = isNodeOfType( + invocation, + "CallExpression", + ) + ? invocation.arguments + : []; + const listenerSymbol = isNodeOfType(listener, "Identifier") + ? resolveRuntimeValueBinding(scopes.scopeFor(listener), listener.name) + : null; + const nativeWindowEventCall = + allowNativeWindowEventListeners && + node.name === "window" && + scopes.isGlobalReference(node) && + !hasVisibleAmbientFunctionDeclaration(scopes.scopeFor(node), node.name) && + isNodeOfType(member, "MemberExpression") && + member.object === node && + !member.computed && + !member.optional && + (getStaticPropertyName(member) === "addEventListener" || + getStaticPropertyName(member) === "removeEventListener") && + isNodeOfType(invocation, "CallExpression") && + invocation.callee === root && + !invocation.optional && + isNodeOfType(eventName, "Literal") && + typeof eventName.value === "string" && + listenerSymbol?.kind === "const" && + hasCompleteSymbolReferences(listenerSymbol, program, scopes) && + listenerSymbol.references.every( + (reference) => + reference.flag === "read" && !isWithinAssignmentTarget(reference.identifier), + ) && + (capture === undefined || + (isNodeOfType(capture, "Literal") && typeof capture.value === "boolean")) && + extraArguments.length === 0; + if (!passiveObjectCall && !nativeWindowEventCall) { unsupported = true; return false; } @@ -153,6 +206,6 @@ export const hasRefNotificationReactCall = ( }); const accepted = new Set(); if (!unsupported) for (const [imported, valid] of imports) if (valid) accepted.add(imported); - certificates.set(scopes, { sourceText, filename, accepted }); + certificates.set(scopes, { sourceText, filename, accepted, allowNativeWindowEventListeners }); return accepted.has(symbol); }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.runtime-values.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.runtime-values.test.ts new file mode 100644 index 0000000000..cd27c428fb --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.runtime-values.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vite-plus/test"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import type { EsTreeNode } from "../utils/es-tree-node.js"; +import { walkAst } from "../utils/walk-ast.js"; +import { analyzeScopes } from "./scope-analysis.js"; +import type { + ReferenceDescriptor, + ReferenceFlag, + SymbolDescriptor, + SymbolKind, +} from "./scope-analysis.js"; + +interface RuntimeResolutionCase { + name: string; + code: string; + expectedKind: SymbolKind | null; + flags: ReferenceFlag[]; +} + +const cases: RuntimeResolutionCase[] = [ + { + name: "destructured parameter beneath interface", + code: "function read({subject}){interface subject{} consume(subject);}", + expectedKind: "parameter", + flags: ["read"], + }, + { + name: "computed key const beneath interface", + code: "const subject='useState'; interface subject{} Object.defineProperty(React,subject,{});", + expectedKind: "const", + flags: ["read"], + }, + { + name: "const before interface", + code: "const subject=1; interface subject{} consume(subject);", + expectedKind: "const", + flags: ["read"], + }, + { + name: "interface before const", + code: "interface subject{} const subject=1; consume(subject);", + expectedKind: "const", + flags: ["read"], + }, + { + name: "const before type alias", + code: "const subject=1; type subject=unknown; consume(subject);", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath inner interface", + code: "const subject=1; function read(){interface subject{} consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath inner type alias", + code: "const subject=1; function read(){type subject=unknown; consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath empty namespace", + code: "const subject=1; namespace Outer{namespace subject{} consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath type-only namespace", + code: "const subject=1; namespace Outer{namespace subject{export interface Marker{}} consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "outer const beneath ambient value", + code: "const subject=1; function read(){declare const subject:unknown; consume(subject);}", + expectedKind: "const", + flags: ["read"], + }, + { + name: "parameter beneath inner interface", + code: "function read(subject){interface subject{} consume(subject);}", + expectedKind: "parameter", + flags: ["read"], + }, + { + name: "parameter beneath inner type alias", + code: "function read(subject){type subject=unknown; consume(subject);}", + expectedKind: "parameter", + flags: ["read"], + }, + { + name: "parameter writes beneath interface", + code: "function read(subject){interface subject{} subject=2; consume(subject);}", + expectedKind: "parameter", + flags: ["write", "read"], + }, + { + name: "nested parameter writes beneath interface", + code: "function read(subject){function update(){interface subject{} subject+=2;} consume(subject);}", + expectedKind: "parameter", + flags: ["read-write", "read"], + }, + { + name: "nearest real parameter shadows outer const", + code: "const subject=1; function read(subject){interface subject{} consume(subject);}", + expectedKind: "parameter", + flags: ["read"], + }, + { + name: "default import beneath interface", + code: "import subject from 'provider'; interface subject{} consume(subject);", + expectedKind: "import", + flags: ["read"], + }, + { + name: "namespace import beneath nested interface", + code: "import * as subject from 'provider'; function read(){interface subject{} consume(subject);}", + expectedKind: "import", + flags: ["read"], + }, + { + name: "named import beneath nested type alias", + code: "import {subject} from 'provider'; function read(){type subject=unknown; consume(subject);}", + expectedKind: "import", + flags: ["read"], + }, + { + name: "named import beneath type-only namespace", + code: "import {subject} from 'provider'; namespace Outer{namespace subject{export type Marker=unknown;} consume(subject);}", + expectedKind: "import", + flags: ["read"], + }, + { + name: "import write beneath nested interface", + code: "import {subject} from 'provider'; function update(){interface subject{} subject=other;}", + expectedKind: "import", + flags: ["write"], + }, + { + name: "function beneath merged interface", + code: "function subject(){} interface subject{} consume(subject);", + expectedKind: "function", + flags: ["read"], + }, + { + name: "class beneath merged interface", + code: "class subject{} interface subject{} consume(subject);", + expectedKind: "class", + flags: ["read"], + }, + { + name: "runtime namespace beneath interface", + code: "namespace subject{export const present=1;} interface subject{} consume(subject);", + expectedKind: "ts-module", + flags: ["read"], + }, + { + name: "latest real var declaration wins", + code: "var subject; var subject; interface subject{} consume(subject);", + expectedKind: "var", + flags: ["read"], + }, + { + name: "value import-equals stays runtime", + code: "import subject=require('provider'); interface subject{} consume(subject);", + expectedKind: "ts-import-equals", + flags: ["read"], + }, + { + name: "interface-only fallback stays bound", + code: "interface subject{} consume(subject);", + expectedKind: "ts-interface", + flags: ["read"], + }, + { + name: "nearest erased fallback stays bound", + code: "type subject=unknown; function read(){interface subject{} consume(subject);}", + expectedKind: "ts-interface", + flags: ["read"], + }, + { + name: "type-alias-only fallback stays bound", + code: "type subject=unknown; consume(subject);", + expectedKind: "ts-type-alias", + flags: ["read"], + }, + { + name: "type-only import fallback stays bound", + code: "import type {subject} from 'provider'; consume(subject);", + expectedKind: "import", + flags: ["read"], + }, + { + name: "type-only import-equals fallback stays bound", + code: "import type subject=require('provider'); consume(subject);", + expectedKind: "ts-import-equals", + flags: ["read"], + }, + { + name: "ambient-only fallback stays bound", + code: "declare const subject:unknown; consume(subject);", + expectedKind: "const", + flags: ["read"], + }, + { + name: "empty namespace fallback stays bound", + code: "namespace subject{} consume(subject);", + expectedKind: "ts-module", + flags: ["read"], + }, + { + name: "type-only namespace fallback stays bound", + code: "namespace subject{export type Marker=unknown;} consume(subject);", + expectedKind: "ts-module", + flags: ["read"], + }, + { + name: "unrelated inner values do not replace erased fallback", + code: "interface subject{} function unrelated(){const subject=1;} consume(subject);", + expectedKind: "ts-interface", + flags: ["read"], + }, + { + name: "unbound value stays global", + code: "consume(subject);", + expectedKind: null, + flags: ["read"], + }, +]; + +describe("runtime reference resolution across erased declarations", () => { + for (const scenario of cases) { + it(scenario.name, () => { + const parsed = parseFixture(scenario.code); + expect(parsed.errors).toEqual([]); + attachParentReferences(parsed.program); + const analysis = analyzeScopes(parsed.program); + const references: ReferenceDescriptor[] = []; + walkAst(parsed.program, (node) => { + if (node.type !== "Identifier" || node.name !== "subject") return; + const reference = analysis.referenceFor(node); + if (reference) references.push(reference); + }); + expect(references.map((reference) => reference.flag)).toEqual(scenario.flags); + const resolved = references[0]?.resolvedSymbol ?? null; + expect(resolved?.kind ?? null).toBe(scenario.expectedKind); + for (const reference of references) { + expect(analysis.symbolFor(reference.identifier)).toBe(resolved); + expect(analysis.isGlobalReference(reference.identifier)).toBe(resolved === null); + if (resolved) expect(resolved.references).toContain(reference); + } + const bindingsByIdentifier = new Map(); + const pending = [analysis.rootScope]; + while (pending.length) { + const scope = pending.pop(); + if (!scope) continue; + pending.push(...scope.children); + for (const binding of scope.symbols) { + const previous = bindingsByIdentifier.get(binding.bindingIdentifier); + if (!previous || previous.id < binding.id) + bindingsByIdentifier.set(binding.bindingIdentifier, binding); + expect(scope.symbolsByName.get(binding.name)?.id).toBe( + scope.symbols.filter((symbol) => symbol.name === binding.name).at(-1)?.id, + ); + } + } + for (const [identifier, binding] of bindingsByIdentifier) + expect(analysis.symbolFor(identifier)?.id).toBe(binding.id); + }); + } +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts index 08f6718178..a7a51a0e5c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/scope-analysis.ts @@ -4,6 +4,7 @@ import { getValuePositionChildKeys } from "../utils/get-value-position-child-key import { isAstNode } from "../utils/is-ast-node.js"; import { isFunctionLike } from "../utils/is-function-like.js"; import { isNodeOfType } from "../utils/is-node-of-type.js"; +import { isErasedTypeScriptBinding } from "../utils/is-erased-typescript-binding.js"; // Scope analyzer — per-file walker building a scope tree, symbol // table, and identifier reference resolution. Mirrors the subset of @@ -856,24 +857,35 @@ const walkValuePositionChildren = (node: EsTreeNode, state: BuilderState): void } }; -// Resolution pass: for each scope's references, walk up the scope -// chain to find the first scope that binds the same name. Set -// `resolvedSymbol` and append to the symbol's `references` list. +// Resolve runtime values first, retaining the nearest erased binding +// when the scope chain contains no runtime value with that name. const resolveReferences = (rootScope: ScopeDescriptor): void => { + const runtimeSymbolsByScope = new Map>(); const visitScope = (scope: ScopeDescriptor): void => { + const runtimeSymbols = new Map(); + for (const symbol of scope.symbols) { + if (!isErasedTypeScriptBinding(symbol)) runtimeSymbols.set(symbol.name, symbol); + } + runtimeSymbolsByScope.set(scope, runtimeSymbols); for (const reference of scope.references) { const name = (reference.identifier as { name?: string }).name; if (typeof name !== "string") continue; let lookup: ScopeDescriptor | null = scope; + let nearestErasedSymbol: SymbolDescriptor | null = null; while (lookup) { - const found = lookup.symbolsByName.get(name); + const found = runtimeSymbolsByScope.get(lookup)?.get(name); if (found) { reference.resolvedSymbol = found; found.references.push(reference); break; } + nearestErasedSymbol ??= lookup.symbolsByName.get(name) ?? null; lookup = lookup.parent; } + if (!reference.resolvedSymbol && nearestErasedSymbol) { + reference.resolvedSymbol = nearestErasedSymbol; + nearestErasedSymbol.references.push(reference); + } } for (const child of scope.children) visitScope(child); }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts new file mode 100644 index 0000000000..1d8c6c9104 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-default-provider-mutation.ts @@ -0,0 +1,65 @@ +import { REACT_PROVIDER_MUTATION_CAPABILITY_NAMES } from "../constants/react.js"; +import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { getEquivalentSymbols } from "./get-equivalent-symbols.js"; +import { getImportDeclarationForSymbol } from "./get-import-declaration-for-symbol.js"; +import { getImportedName } from "./get-imported-name.js"; +import { getStaticPropertyName } from "./get-static-property-name.js"; +import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; +import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { isNonEscapingReactApiReference } from "./is-non-escaping-react-api-reference.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +const isDefaultImport = (symbol: SymbolDescriptor): boolean => + isNodeOfType(symbol.declarationNode, "ImportDefaultSpecifier") || + getImportedName(symbol.declarationNode) === "default"; + +const isReactImport = (symbol: SymbolDescriptor): boolean => { + const declaration = getImportDeclarationForSymbol(symbol); + return Boolean( + declaration && + declaration.source.value === "react" && + declaration.importKind !== "type" && + (!isNodeOfType(symbol.declarationNode, "ImportSpecifier") || + symbol.declarationNode.importKind !== "type"), + ); +}; + +export const hasReactDefaultProviderMutation = ( + propertyName: string, + scopes: ScopeAnalysis, +): boolean => { + return scopes.rootScope.symbols.some((symbol) => { + if (!isReactImport(symbol)) return false; + const isNamespaceImport = isNodeOfType(symbol.declarationNode, "ImportNamespaceSpecifier"); + if (!isDefaultImport(symbol) && !isNamespaceImport) return false; + if (hasPossibleStaticPropertyMutationOrEscape(symbol.bindingIdentifier, propertyName, scopes)) { + return true; + } + return getEquivalentSymbols(symbol.bindingIdentifier, scopes).some((alias) => + alias.references.some((reference) => { + if (reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier)) { + return true; + } + const referenceRoot = findTransparentExpressionRoot(reference.identifier); + const member = referenceRoot.parent; + if (!isNodeOfType(member, "MemberExpression") || member.object !== referenceRoot) { + return !( + isNodeOfType(member, "VariableDeclarator") && + member.init === referenceRoot && + isNodeOfType(member.id, "Identifier") && + isNodeOfType(member.parent, "VariableDeclaration") && + member.parent.kind === "const" + ); + } + const referencedProperty = getStaticPropertyName(member); + return ( + referencedProperty === null || + REACT_PROVIDER_MUTATION_CAPABILITY_NAMES.has(referencedProperty) || + (isNamespaceImport && referencedProperty === "default") || + (referencedProperty === propertyName && !isNonEscapingReactApiReference(member)) + ); + }), + ); + }); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts new file mode 100644 index 0000000000..fbefa50736 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vite-plus/test"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import { hasRuntimeValueBinding } from "./has-runtime-value-binding.js"; + +describe("hasRuntimeValueBinding", () => { + it.each([ + { code: "namespace candidate {}", expected: false, nested: false }, + { code: "namespace candidate { interface Marker {} }", expected: false, nested: false }, + { code: "namespace candidate { export interface Marker {} }", expected: false, nested: false }, + { code: "namespace candidate { type Marker = string; }", expected: false, nested: false }, + { + code: "namespace candidate { export type Marker = string; }", + expected: false, + nested: false, + }, + { + code: "namespace candidate { export namespace Nested { export interface Marker {} } }", + expected: false, + nested: false, + }, + { code: "namespace candidate { export const marker = 0; }", expected: true, nested: false }, + { code: "namespace candidate { const marker = 0; }", expected: true, nested: false }, + { code: "namespace candidate { export function marker() {} }", expected: true, nested: false }, + { code: "namespace candidate { export class Marker {} }", expected: true, nested: false }, + { code: "namespace candidate { export enum Marker { Value } }", expected: true, nested: false }, + { + code: "namespace candidate { export namespace Nested { export const marker = 0; } }", + expected: true, + nested: false, + }, + { + code: "namespace candidate { type Marker = string; export const marker = 0; }", + expected: true, + nested: false, + }, + { code: "namespace candidate {;}", expected: true, nested: false }, + { + code: "namespace candidate { declare const marker: unknown; }", + expected: true, + nested: false, + }, + { + code: "namespace candidate { export declare const marker: unknown; }", + expected: true, + nested: false, + }, + { code: "namespace candidate { import Alias = Some.Value; }", expected: false, nested: false }, + { + code: "namespace candidate { export import Alias = Some.Value; }", + expected: true, + nested: false, + }, + { + code: "function candidate() {} namespace candidate { export interface Marker {} }", + expected: true, + nested: false, + }, + { code: "interface candidate {}", expected: false, nested: false }, + { code: "type candidate = unknown;", expected: false, nested: false }, + { code: "declare const candidate: unknown;", expected: false, nested: false }, + { code: "declare class candidate {}", expected: false, nested: false }, + { code: 'import type candidate from "provider";', expected: false, nested: false }, + { code: 'import type candidate = require("provider");', expected: false, nested: false }, + { code: "const candidate = 0;", expected: true, nested: false }, + { code: "let candidate = 0;", expected: true, nested: false }, + { code: "var candidate = 0;", expected: true, nested: false }, + { code: "const candidate = 0; interface candidate {}", expected: true, nested: false }, + { code: "interface candidate {} const candidate = 0;", expected: true, nested: false }, + { code: "const candidate = 0; type candidate = unknown;", expected: true, nested: false }, + { code: "class candidate {} interface candidate {}", expected: true, nested: false }, + { code: "function candidate() {} interface candidate {}", expected: true, nested: false }, + { code: 'import candidate from "provider";', expected: true, nested: false }, + { code: 'import candidate = require("provider");', expected: true, nested: false }, + { + code: "const candidate = 0; function owner() { interface candidate {} }", + expected: true, + nested: true, + }, + { + code: "interface candidate {} function owner() { const candidate = 0; }", + expected: false, + nested: false, + }, + { + code: "declare const candidate: unknown; function owner() { interface candidate {} }", + expected: false, + nested: true, + }, + { + code: "function owner(candidate: unknown) { interface candidate {} }", + expected: true, + nested: true, + }, + { code: "const unrelated = 0; interface candidate {}", expected: false, nested: false }, + ])("resolves $code", ({ code, expected, nested }) => { + const parsed = parseFixture(code); + expect(parsed.errors).toEqual([]); + attachParentReferences(parsed.program); + const scopes = analyzeScopes(parsed.program); + const bindingScope = nested + ? scopes.rootScope.children.find((scope) => scope.kind === "function") + : scopes.rootScope; + const symbol = bindingScope?.symbols.findLast((binding) => binding.name === "candidate"); + expect(symbol).toBeDefined(); + if (!symbol) throw new Error("Missing fixture binding"); + expect(hasRuntimeValueBinding(symbol)).toBe(expected); + expect(hasRuntimeValueBinding(symbol)).toBe(expected); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.ts new file mode 100644 index 0000000000..0fdd76042e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-runtime-value-binding.ts @@ -0,0 +1,23 @@ +import type { ScopeDescriptor, SymbolDescriptor } from "../semantic/scope-analysis.js"; +import { isErasedTypeScriptBinding } from "./is-erased-typescript-binding.js"; + +const runtimeValueBindings = new WeakMap(); + +export const hasRuntimeValueBinding = (symbol: SymbolDescriptor): boolean => { + const cached = runtimeValueBindings.get(symbol); + if (cached !== undefined) return cached; + let scope: ScopeDescriptor | null = symbol.scope; + while (scope) { + if ( + scope.symbols.some( + (candidate) => candidate.name === symbol.name && !isErasedTypeScriptBinding(candidate), + ) + ) { + runtimeValueBindings.set(symbol, true); + return true; + } + scope = scope.parent; + } + runtimeValueBindings.set(symbol, false); + return false; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-binding-assignment-target.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-binding-assignment-target.ts new file mode 100644 index 0000000000..c2715d0893 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-binding-assignment-target.ts @@ -0,0 +1,36 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const isBindingAssignmentTarget = (identifier: EsTreeNode): boolean => { + if (!isNodeOfType(identifier, "Identifier")) return false; + let currentNode = findTransparentExpressionRoot(identifier); + while (currentNode.parent) { + const parentNode = currentNode.parent; + if (isNodeOfType(parentNode, "AssignmentExpression")) { + return parentNode.left === currentNode; + } + if (isNodeOfType(parentNode, "UpdateExpression")) { + return parentNode.argument === currentNode; + } + if (isNodeOfType(parentNode, "ForInStatement") || isNodeOfType(parentNode, "ForOfStatement")) { + return parentNode.left === currentNode; + } + if (isNodeOfType(parentNode, "Property")) { + if (parentNode.value !== currentNode || !isNodeOfType(parentNode.parent, "ObjectPattern")) { + return false; + } + } else if (isNodeOfType(parentNode, "AssignmentPattern")) { + if (parentNode.left !== currentNode) return false; + } else if (isNodeOfType(parentNode, "RestElement")) { + if (parentNode.argument !== currentNode) return false; + } else if ( + !isNodeOfType(parentNode, "ArrayPattern") && + !isNodeOfType(parentNode, "ObjectPattern") + ) { + return false; + } + currentNode = findTransparentExpressionRoot(parentNode); + } + return false; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts new file mode 100644 index 0000000000..48c3d151dc --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import { isErasedTypeScriptBinding } from "./is-erased-typescript-binding.js"; + +describe("isErasedTypeScriptBinding", () => { + it.each([ + { code: "namespace candidate {}", expected: true }, + { code: "namespace candidate { interface Marker {} }", expected: true }, + { code: "namespace candidate { export interface Marker {} }", expected: true }, + { code: "namespace candidate { type Marker = string; }", expected: true }, + { code: "namespace candidate { export type Marker = string; }", expected: true }, + { + code: "namespace candidate { export namespace Nested { export interface Marker {} } }", + expected: true, + }, + { code: "namespace candidate { export const marker = 0; }", expected: false }, + { code: "namespace candidate { const marker = 0; }", expected: false }, + { code: "namespace candidate { export function marker() {} }", expected: false }, + { code: "namespace candidate { export class Marker {} }", expected: false }, + { code: "namespace candidate { export enum Marker { Value } }", expected: false }, + { + code: "namespace candidate { export namespace Nested { export const marker = 0; } }", + expected: false, + }, + { + code: "namespace candidate { type Marker = string; export const marker = 0; }", + expected: false, + }, + { code: "namespace candidate {;}", expected: false }, + { code: "namespace candidate { declare const marker: unknown; }", expected: false }, + { code: "namespace candidate { export declare const marker: unknown; }", expected: false }, + { code: "namespace candidate { import Alias = Some.Value; }", expected: true }, + { code: "namespace candidate { export import Alias = Some.Value; }", expected: false }, + { code: "declare const candidate: unknown;", expected: true }, + { code: "declare let candidate: unknown;", expected: true }, + { code: "declare var candidate: unknown;", expected: true }, + { code: "declare class candidate {}", expected: true }, + { code: "export declare const candidate: unknown;", expected: true }, + { code: "interface candidate {}", expected: true }, + { code: "type candidate = unknown;", expected: true }, + { code: 'import type candidate from "provider";', expected: true }, + { code: 'import type * as candidate from "provider";', expected: true }, + { code: 'import { type candidate } from "provider";', expected: true }, + { code: 'import type candidate = require("provider");', expected: true }, + { code: 'import candidate = require("provider");', expected: false }, + { code: "const candidate = 0;", expected: false }, + { code: "let candidate = 0;", expected: false }, + { code: "var candidate = 0;", expected: false }, + { code: "class candidate {}", expected: false }, + { code: "function candidate() {}", expected: false }, + { code: 'import { candidate } from "provider";', expected: false }, + { code: 'import candidate from "provider";', expected: false }, + { code: 'import * as candidate from "provider";', expected: false }, + ])("classifies $code", ({ code, expected }) => { + const parsed = parseFixture(code); + expect(parsed.errors).toEqual([]); + attachParentReferences(parsed.program); + const scopes = analyzeScopes(parsed.program); + const symbol = scopes.rootScope.symbols.find((binding) => binding.name === "candidate"); + expect(symbol && isErasedTypeScriptBinding(symbol)).toBe(expected); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts new file mode 100644 index 0000000000..2b535d26d5 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-binding.ts @@ -0,0 +1,22 @@ +import type { SymbolDescriptor } from "../semantic/scope-analysis.js"; +import { isErasedTypeScriptNamespace } from "./is-erased-typescript-namespace.js"; +import { getValueImportDeclaration } from "./get-value-import-declaration.js"; + +export const isErasedTypeScriptBinding = (symbol: SymbolDescriptor): boolean => { + if (symbol.kind === "ts-interface" || symbol.kind === "ts-type-alias") return true; + if (symbol.kind === "import") return getValueImportDeclaration(symbol) === null; + if ( + symbol.kind === "ts-import-equals" && + "importKind" in symbol.declarationNode && + symbol.declarationNode.importKind === "type" + ) + return true; + if (symbol.kind === "ts-module" && isErasedTypeScriptNamespace(symbol.declarationNode)) + return true; + let declaration = symbol.declarationNode; + while (declaration.parent) { + if ("declare" in declaration && declaration.declare === true) return true; + declaration = declaration.parent; + } + return false; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.test.ts new file mode 100644 index 0000000000..461e7b930e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isErasedTypeScriptNamespace } from "./is-erased-typescript-namespace.js"; + +describe("dotted TypeScript namespace erasure", () => { + it.each([ + { code: "namespace candidate.Nested {}", expected: true }, + { code: "namespace candidate.Nested { export interface Marker {} }", expected: true }, + { code: "namespace candidate.Nested { export type Marker = string; }", expected: true }, + { code: "namespace candidate.Nested { export const marker = 0; }", expected: false }, + ])("classifies $code", ({ code, expected }) => { + const parsed = parseFixture(code); + expect(parsed.errors).toEqual([]); + if (!isNodeOfType(parsed.program, "Program")) throw new Error("Expected namespace program"); + const namespace = parsed.program.body[0]; + expect(namespace && isErasedTypeScriptNamespace(namespace)).toBe(expected); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.ts new file mode 100644 index 0000000000..a1cfc39812 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-erased-typescript-namespace.ts @@ -0,0 +1,27 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +const erasedNamespaces = new WeakMap(); + +export const isErasedTypeScriptNamespace = (node: EsTreeNode): boolean => { + const cached = erasedNamespaces.get(node); + if (cached !== undefined) return cached; + let isErased = false; + if (isNodeOfType(node, "TSInterfaceDeclaration") || isNodeOfType(node, "TSTypeAliasDeclaration")) + isErased = true; + else if (isNodeOfType(node, "TSImportEqualsDeclaration")) + isErased = + node.importKind === "type" || + !isNodeOfType(node.moduleReference, "TSExternalModuleReference"); + else if (isNodeOfType(node, "ExportNamedDeclaration")) + isErased = isNodeOfType(node.declaration, "TSImportEqualsDeclaration") + ? node.declaration.importKind === "type" + : Boolean(node.declaration && isErasedTypeScriptNamespace(node.declaration)); + else if (isNodeOfType(node, "TSModuleDeclaration") && node.body) + isErased = isNodeOfType(node.body, "TSModuleDeclaration") + ? isErasedTypeScriptNamespace(node.body) + : isNodeOfType(node.body, "TSModuleBlock") && + node.body.body.every(isErasedTypeScriptNamespace); + erasedNamespaces.set(node, isErased); + return isErased; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-member-mutation-target.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-member-mutation-target.ts new file mode 100644 index 0000000000..6d2fade0ee --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-member-mutation-target.ts @@ -0,0 +1,38 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const isMemberMutationTarget = (memberExpression: EsTreeNode): boolean => { + let currentNode = findTransparentExpressionRoot(memberExpression); + while (currentNode.parent) { + const parentNode = currentNode.parent; + if (isNodeOfType(parentNode, "AssignmentExpression")) { + return parentNode.left === currentNode; + } + if ( + isNodeOfType(parentNode, "UpdateExpression") || + (isNodeOfType(parentNode, "UnaryExpression") && parentNode.operator === "delete") + ) { + return parentNode.argument === currentNode; + } + if (isNodeOfType(parentNode, "ForInStatement") || isNodeOfType(parentNode, "ForOfStatement")) { + return parentNode.left === currentNode; + } + if (isNodeOfType(parentNode, "Property")) { + if (parentNode.value !== currentNode || !isNodeOfType(parentNode.parent, "ObjectPattern")) { + return false; + } + } else if (isNodeOfType(parentNode, "AssignmentPattern")) { + if (parentNode.left !== currentNode) return false; + } else if (isNodeOfType(parentNode, "RestElement")) { + if (parentNode.argument !== currentNode) return false; + } else if ( + !isNodeOfType(parentNode, "ArrayPattern") && + !isNodeOfType(parentNode, "ObjectPattern") + ) { + return false; + } + currentNode = findTransparentExpressionRoot(parentNode); + } + return false; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-non-escaping-react-api-reference.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-non-escaping-react-api-reference.ts new file mode 100644 index 0000000000..4ee0d55702 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-non-escaping-react-api-reference.ts @@ -0,0 +1,14 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const isNonEscapingReactApiReference = (expression: EsTreeNode): boolean => { + const referenceRoot = findTransparentExpressionRoot(expression); + const parent = referenceRoot.parent; + return ( + (isNodeOfType(parent, "CallExpression") && parent.callee === referenceRoot) || + (isNodeOfType(parent, "MemberExpression") && parent.object === referenceRoot) || + (isNodeOfType(parent, "Property") && parent.computed && parent.key === referenceRoot) || + (isNodeOfType(parent, "AssignmentPattern") && parent.right === referenceRoot) + ); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.performance.test.ts new file mode 100644 index 0000000000..d1396fab8d --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.performance.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { runRule } from "../../test-utils/run-rule.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { + NUMERIC_DATA_LARGE_READ_COUNT, + NUMERIC_DATA_MAXIMUM_VISIT_SCALING_MULTIPLIER, + NUMERIC_DATA_SMALL_READ_COUNT, +} from "../constants/thresholds.js"; +import { hookUseState } from "../rules/react-builtins/hook-use-state.js"; + +interface NumericDataWork { + programWalkCount: number; + visitedNodeCount: number; +} + +const traversalWork = vi.hoisted(() => ({ programWalkCount: 0, visitedNodeCount: 0 })); + +vi.mock("./walk-ast.js", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + walkAst: (node: EsTreeNode, visitor: (child: EsTreeNode) => boolean | void): void => { + if (node.type === "Program") traversalWork.programWalkCount += 1; + originalModule.walkAst(node, (child) => { + traversalWork.visitedNodeCount += 1; + return visitor(child); + }); + }, + }; +}); + +const buildSource = (readCount: number, replaceNumber = false): string => { + const reads = Array.from({ length: readCount }, () => "values[getKey()]").join(" + "); + return `import {useState} from "react"; + ${replaceNumber ? "Number = customNumber;" : ""} + export const Panel = input => { + const [stored] = useState(7); + const values = [3, 7]; + const getKey = () => Number(input); + const total = ${reads}; + return {stored + total}; + };`; +}; + +const measureWork = (readCount: number): NumericDataWork => { + traversalWork.programWalkCount = 0; + traversalWork.visitedNodeCount = 0; + const result = runRule(hookUseState, buildSource(readCount)); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toEqual([]); + return { ...traversalWork }; +}; + +describe("numeric data provider work", () => { + it("amortizes stable program and binding facts across computed reads", () => { + const small = measureWork(NUMERIC_DATA_SMALL_READ_COUNT); + const large = measureWork(NUMERIC_DATA_LARGE_READ_COUNT); + expect(small.programWalkCount).toBeGreaterThan(0); + expect(large.programWalkCount).toBe(small.programWalkCount); + expect(small.visitedNodeCount).toBeGreaterThan(0); + expect(large.visitedNodeCount).toBeLessThan( + small.visitedNodeCount * NUMERIC_DATA_MAXIMUM_VISIT_SCALING_MULTIPLIER, + ); + }); + + it("still reports a visible Number replacement on a fresh program", () => { + const result = runRule(hookUseState, buildSource(NUMERIC_DATA_SMALL_READ_COUNT, true)); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + const clean = runRule(hookUseState, buildSource(NUMERIC_DATA_SMALL_READ_COUNT)); + expect(clean.parseErrors).toEqual([]); + expect(clean.diagnostics).toEqual([]); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.test.ts new file mode 100644 index 0000000000..37bba81fc7 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isNumericDataMemberAccess } from "./is-numeric-data-member-access.js"; +import { walkAst } from "./walk-ast.js"; +import { + NUMERIC_DATA_LARGE_READ_COUNT, + NUMERIC_DATA_SMALL_READ_COUNT, +} from "../constants/thresholds.js"; + +const traversalWork = vi.hoisted(() => ({ programWalkCount: 0 })); + +vi.mock("./walk-ast.js", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + walkAst: (node: EsTreeNode, visitor: (child: EsTreeNode) => boolean | void): void => { + if (node.type === "Program") traversalWork.programWalkCount += 1; + originalModule.walkAst(node, visitor); + }, + }; +}); + +const prepare = (code: string) => { + const parsed = parseFixture(code); + expect(parsed.errors).toEqual([]); + attachParentReferences(parsed.program); + const members: EsTreeNode[] = []; + walkAst(parsed.program, (node) => { + if (isNodeOfType(node, "MemberExpression") && node.computed) members.push(node); + }); + expect(members.length).toBeGreaterThan(0); + return { program: parsed.program, members, scopes: analyzeScopes(parsed.program) }; +}; + +const source = "const getKey=()=>Number(input);const value=items[getKey()]-1;"; + +describe("numeric data stable facts lifetime", () => { + it("indexes exported bindings once across distinct numeric helper functions", () => { + const programWalkCounts = [NUMERIC_DATA_SMALL_READ_COUNT, NUMERIC_DATA_LARGE_READ_COUNT].map( + (helperCount) => { + const fixture = prepare( + Array.from( + { length: helperCount }, + (_, helperIndex) => + `const getKey${helperIndex}=()=>Number(input);const value${helperIndex}=items[getKey${helperIndex}()]-1;`, + ).join("\n"), + ); + traversalWork.programWalkCount = 0; + expect( + fixture.members.map((member) => isNumericDataMemberAccess(member, fixture.scopes)), + ).toEqual(fixture.members.map(() => true)); + return traversalWork.programWalkCount; + }, + ); + expect(programWalkCounts[0]).toBeGreaterThan(0); + expect(programWalkCounts[1]).toBe(programWalkCounts[0]); + }); + + it.each([ + "const getKey=()=>Number(input);export {getKey};const value=items[getKey()]-1;", + "const getKey=()=>Number(input);export default getKey;const value=items[getKey()]-1;", + ])("declines exported numeric helpers: %s", (code) => { + const fixture = prepare(code); + expect( + fixture.members.map((member) => isNumericDataMemberAccess(member, fixture.scopes)), + ).toEqual(fixture.members.map(() => false)); + }); + + it("reuses facts only within the exact analysis and Program", () => { + const fixture = prepare(source); + traversalWork.programWalkCount = 0; + expect( + fixture.members.map((member) => isNumericDataMemberAccess(member, fixture.scopes)), + ).toEqual([true]); + const coldWalks = traversalWork.programWalkCount; + expect(coldWalks).toBeGreaterThan(0); + traversalWork.programWalkCount = 0; + expect( + fixture.members.map((member) => isNumericDataMemberAccess(member, fixture.scopes)), + ).toEqual([true]); + expect(traversalWork.programWalkCount).toBe(0); + const freshScopes = analyzeScopes(fixture.program); + traversalWork.programWalkCount = 0; + expect(fixture.members.map((member) => isNumericDataMemberAccess(member, freshScopes))).toEqual( + [true], + ); + expect(traversalWork.programWalkCount).toBe(coldWalks); + const changed = prepare(`Number=identity;${source}`); + expect( + changed.members.map((member) => isNumericDataMemberAccess(member, changed.scopes)), + ).toEqual([false]); + const fresh = prepare(source); + expect(fresh.members.map((member) => isNumericDataMemberAccess(member, fresh.scopes))).toEqual([ + true, + ]); + }); + + it.each([ + "const getKey=()=>key;const before=items[getKey()]-1;const key=Number(input);const after=items[getKey()]-1;", + "function getKey(){return other()} function other(){return getKey()} const value=items[getKey()]-1;", + ])("keeps evaluation-position and visiting refusals uncached: %s", (code) => { + const fixture = prepare(code); + const expected = fixture.members.map(() => false); + for (const members of [fixture.members, [...fixture.members].reverse(), fixture.members]) + expect(members.map((member) => isNumericDataMemberAccess(member, fixture.scopes))).toEqual( + expected, + ); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.ts new file mode 100644 index 0000000000..20ba47d8cf --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-numeric-data-member-access.ts @@ -0,0 +1,317 @@ +import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; +import { findEnclosingFunction } from "./find-enclosing-function.js"; +import { findProgramRoot } from "./find-program-root.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { getDirectConstInitializer } from "./get-direct-const-initializer.js"; +import { getFunctionBindingSymbols } from "./get-function-binding-symbols.js"; +import { getStaticPropertyName } from "./get-static-property-name.js"; +import { hasCompleteSymbolReferences } from "./has-complete-symbol-references.js"; +import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { isFunctionLike } from "./is-function-like.js"; +import { resolveRuntimeValueBinding } from "./resolve-runtime-value-binding.js"; +import { isMemberMutationTarget } from "./is-member-mutation-target.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { stripParenExpression } from "./strip-paren-expression.js"; +import { walkAst } from "./walk-ast.js"; + +interface NumericDataProgramFacts { + hasNumberWrite: boolean | null; + exportedBindings: Set; + readonlyBindings: Map; + directCalls: Map[] | null>; + earliestCalls: Map; +} + +const programFacts = new WeakMap>(); + +export const isNumericDataMemberAccess = (member: EsTreeNode, scopes: ScopeAnalysis): boolean => { + if (!isNodeOfType(member, "MemberExpression") || !member.computed || member.optional) + return false; + const program = findProgramRoot(member); + if (!program) return false; + let scopeFacts = programFacts.get(scopes); + if (!scopeFacts) { + scopeFacts = new WeakMap(); + programFacts.set(scopes, scopeFacts); + } + let facts = scopeFacts.get(program); + if (!facts) { + facts = { + hasNumberWrite: null, + exportedBindings: new Set(), + readonlyBindings: new Map(), + directCalls: new Map(), + earliestCalls: new Map(), + }; + scopeFacts.set(program, facts); + } + if (facts.hasNumberWrite === null) { + facts.hasNumberWrite = false; + walkAst(program, (node) => { + if ( + isNodeOfType(node, "Identifier") && + node.name === "Number" && + scopes.isGlobalReference(node) && + isBindingAssignmentTarget(node) + ) + facts.hasNumberWrite = true; + if (isNodeOfType(node, "ExportNamedDeclaration") && !node.source) { + for (const specifier of node.specifiers) { + if (!isNodeOfType(specifier, "ExportSpecifier")) continue; + const localName = specifier.local.name; + const binding = localName && resolveRuntimeValueBinding(scopes.rootScope, localName); + if (binding) facts.exportedBindings.add(binding); + } + } + if (isNodeOfType(node, "ExportDefaultDeclaration")) { + const exportedValue = stripParenExpression(node.declaration); + const binding = + isNodeOfType(exportedValue, "Identifier") && + resolveRuntimeValueBinding(scopes.rootScope, exportedValue.name); + if (binding) facts.exportedBindings.add(binding); + } + }); + } + if (facts.hasNumberWrite) return false; + const isReadonlyBinding = (symbol: SymbolDescriptor): boolean => { + const cached = facts.readonlyBindings.get(symbol); + if (cached !== undefined) return cached; + const isReadonly = + hasCompleteSymbolReferences(symbol, program, scopes) && + symbol.references.every( + (reference) => + reference.flag === "read" && !isBindingAssignmentTarget(reference.identifier), + ); + facts.readonlyBindings.set(symbol, isReadonly); + return isReadonly; + }; + const getDirectFunction = ( + symbol: SymbolDescriptor, + ): EsTreeNodeOfType< + "ArrowFunctionExpression" | "FunctionExpression" | "FunctionDeclaration" + > | null => { + const initializer = getDirectConstInitializer(symbol); + const candidate = initializer ? stripParenExpression(initializer) : symbol.declarationNode; + return isFunctionLike(candidate) && + !candidate.async && + !candidate.generator && + (symbol.kind === "const" || symbol.kind === "function") && + isReadonlyBinding(symbol) + ? candidate + : null; + }; + const getDirectCalls = ( + symbol: SymbolDescriptor, + ): EsTreeNodeOfType<"CallExpression">[] | null => { + if (facts.directCalls.has(symbol)) return facts.directCalls.get(symbol) ?? null; + facts.directCalls.set(symbol, null); + if (!isReadonlyBinding(symbol) || symbol.references.length === 0) return null; + const declaration = symbol.declarationNode; + const statement = isNodeOfType(declaration, "VariableDeclarator") + ? declaration.parent + : declaration; + if ( + isNodeOfType(statement?.parent, "ExportNamedDeclaration") || + isNodeOfType(statement?.parent, "ExportDefaultDeclaration") + ) + return null; + if (facts.exportedBindings.has(symbol)) return null; + const initializer = getDirectConstInitializer(symbol); + const calls: EsTreeNodeOfType<"CallExpression">[] = []; + let earliestCallPosition = program.range[1]; + for (const reference of symbol.references) { + const root = findTransparentExpressionRoot(reference.identifier); + const invocation = root.parent; + if ( + !isNodeOfType(invocation, "CallExpression") || + invocation.callee !== root || + invocation.optional || + invocation.arguments.some((argument) => isNodeOfType(argument, "SpreadElement")) + ) + return null; + if (initializer && invocation.range[0] < initializer.range[1]) return null; + calls.push(invocation); + earliestCallPosition = Math.min(earliestCallPosition, invocation.range[0]); + } + facts.earliestCalls.set(symbol, earliestCallPosition); + facts.directCalls.set(symbol, calls); + return calls; + }; + const isNumericKey = ( + expression: EsTreeNode, + visiting: ReadonlySet, + evaluationPosition = expression.range[0], + ): boolean => { + const candidate = stripParenExpression(expression); + if (isNodeOfType(candidate, "Literal")) return typeof candidate.value === "number"; + if (isNodeOfType(candidate, "UnaryExpression")) + return candidate.operator === "+" || candidate.operator === "-" || candidate.operator === "~"; + if (isNodeOfType(candidate, "BinaryExpression")) + return ["-", "*", "/", "%", "**", "<<", ">>", ">>>", "&", "|", "^"].includes( + candidate.operator, + ); + if (isNodeOfType(candidate, "CallExpression")) { + if ( + candidate.optional || + candidate.arguments.some((argument) => isNodeOfType(argument, "SpreadElement")) + ) + return false; + const callee = stripParenExpression(candidate.callee); + if (!isNodeOfType(callee, "Identifier")) return false; + if (callee.name === "Number" && scopes.isGlobalReference(callee)) return true; + const symbol = scopes.symbolFor(callee); + if (!symbol || visiting.has(symbol)) return false; + const owner = getDirectFunction(symbol); + const calls = owner && getDirectCalls(symbol); + if (!owner || !calls) return false; + const earliestCall = Math.min( + evaluationPosition, + facts.earliestCalls.get(symbol) ?? evaluationPosition, + ); + const nextVisiting = new Set(visiting).add(symbol); + if (!isNodeOfType(owner.body, "BlockStatement")) + return isNumericKey(owner.body, nextVisiting, earliestCall); + const [statement, ...remainingStatements] = owner.body.body; + return ( + remainingStatements.length === 0 && + isNodeOfType(statement, "ReturnStatement") && + Boolean(statement.argument && isNumericKey(statement.argument, nextVisiting, earliestCall)) + ); + } + if (!isNodeOfType(candidate, "Identifier")) return false; + const symbol = scopes.symbolFor(candidate); + if (!symbol || visiting.has(symbol) || !isReadonlyBinding(symbol)) return false; + const nextVisiting = new Set(visiting).add(symbol); + const initializer = getDirectConstInitializer(symbol); + if (initializer) + return ( + initializer.range[1] <= evaluationPosition && + isNumericKey(initializer, nextVisiting, initializer.range[0]) + ); + if (symbol.kind !== "parameter") return false; + const owner = symbol.scope.node; + if (!isFunctionLike(owner) || owner.async || owner.generator) return false; + const position = owner.params.findIndex((parameter) => parameter === symbol.bindingIdentifier); + if (position < 0) return false; + const bindings = getFunctionBindingSymbols(owner, scopes); + if (bindings.length === 0) return false; + return bindings.every((binding) => { + if (getDirectFunction(binding) !== owner) return false; + const calls = getDirectCalls(binding); + return Boolean( + calls && + calls.every( + (call) => + isNodeOfType(call, "CallExpression") && + call.arguments[position] && + isNumericKey(call.arguments[position], nextVisiting, call.range[0]), + ), + ); + }); + }; + const getFreshArray = (expression: EsTreeNode): SymbolDescriptor | null => { + const candidate = stripParenExpression(expression); + if (!isNodeOfType(candidate, "Identifier")) return null; + const symbol = scopes.symbolFor(candidate); + const initializer = symbol && getDirectConstInitializer(symbol); + return symbol && + initializer && + isNodeOfType(stripParenExpression(initializer), "ArrayExpression") && + isReadonlyBinding(symbol) + ? symbol + : null; + }; + const isNativeIndexOfArgument = (expression: EsTreeNode): boolean => { + const invocation = expression.parent; + if ( + !isNodeOfType(invocation, "CallExpression") || + invocation.optional || + invocation.arguments.some((argument) => isNodeOfType(argument, "SpreadElement")) + ) + return false; + const [argument, ...remainingArguments] = invocation.arguments; + if (argument !== expression || remainingArguments.length !== 0) return false; + const callee = stripParenExpression(invocation.callee); + if ( + !isNodeOfType(callee, "MemberExpression") || + callee.computed || + callee.optional || + getStaticPropertyName(callee) !== "indexOf" + ) + return false; + const array = getFreshArray(callee.object); + if (!array) return false; + const owner = findEnclosingFunction(invocation); + for ( + let ancestor = invocation.parent; + ancestor && ancestor !== owner; + ancestor = ancestor.parent + ) { + if ( + [ + "ForStatement", + "ForInStatement", + "ForOfStatement", + "WhileStatement", + "DoWhileStatement", + ].includes(ancestor.type) + ) + return false; + } + return array.references.every((reference) => { + if (findEnclosingFunction(reference.identifier) !== owner) return false; + const root = findTransparentExpressionRoot(reference.identifier); + if (root.range[0] >= invocation.range[0]) return true; + const access = root.parent; + if (!isNodeOfType(access, "MemberExpression") || access.object !== root || access.optional) + return false; + if (access.computed) return isNumericKey(access.property, new Set()); + const accessRoot = findTransparentExpressionRoot(access); + const operation = accessRoot.parent; + return ( + isNodeOfType(operation, "CallExpression") && + operation.callee === accessRoot && + !operation.optional && + operation.arguments.length === 0 && + getStaticPropertyName(access) === "reverse" + ); + }); + }; + const isDataUse = (expression: EsTreeNode, visiting: ReadonlySet): boolean => { + const root = findTransparentExpressionRoot(expression); + const parent = root.parent; + if (isNodeOfType(parent, "BinaryExpression")) + return parent.operator !== "in" && parent.operator !== "instanceof"; + if (isNodeOfType(parent, "UnaryExpression")) return parent.operator !== "delete"; + if (isNativeIndexOfArgument(root)) return true; + if ( + !isNodeOfType(parent, "VariableDeclarator") || + parent.init !== root || + !isNodeOfType(parent.id, "Identifier") + ) + return false; + const symbol = scopes.symbolFor(parent.id); + if ( + !symbol || + visiting.has(symbol) || + getDirectConstInitializer(symbol) !== root || + !isReadonlyBinding(symbol) || + symbol.references.length === 0 + ) + return false; + const nextVisiting = new Set(visiting).add(symbol); + return symbol.references.every((reference) => isDataUse(reference.identifier, nextVisiting)); + }; + if (!isNumericKey(member.property, new Set())) return false; + if (!isMemberMutationTarget(member)) return isDataUse(member, new Set()); + const root = findTransparentExpressionRoot(member); + const assignment = root.parent; + return ( + isNodeOfType(assignment, "AssignmentExpression") && + assignment.left === root && + assignment.operator === "=" && + Boolean(getFreshArray(member.object)) + ); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-proven-unmodified-react-api-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-proven-unmodified-react-api-call.ts new file mode 100644 index 0000000000..7710ba0af3 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-proven-unmodified-react-api-call.ts @@ -0,0 +1,70 @@ +import type { ScopeAnalysis } from "../semantic/scope-analysis.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { getEquivalentSymbols } from "./get-equivalent-symbols.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { getStaticPropertyName } from "./get-static-property-name.js"; +import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; +import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { isNonEscapingReactApiReference } from "./is-non-escaping-react-api-reference.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isReactApiCall } from "./is-react-api-call.js"; +import { stripParenExpression } from "./strip-paren-expression.js"; + +export const isProvenUnmodifiedReactApiCall = ( + call: EsTreeNode, + apiNames: string | ReadonlySet, + scopes: ScopeAnalysis, +): boolean => { + const invocation = stripParenExpression(call); + if ( + !isNodeOfType(invocation, "CallExpression") || + !isReactApiCall(invocation, apiNames, scopes) + ) { + return false; + } + const callee = stripParenExpression(invocation.callee); + const identifier = getRootIdentifier(callee); + const symbol = identifier ? scopes.symbolFor(identifier) : null; + if ( + !identifier || + symbol?.kind !== "import" || + symbol.references.some( + (reference) => reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier), + ) + ) { + return false; + } + if (isNodeOfType(callee, "MemberExpression")) { + const propertyName = getStaticPropertyName(callee); + if ( + !propertyName || + hasPossibleStaticPropertyMutationOrEscape(identifier, propertyName, scopes) + ) { + return false; + } + return getEquivalentSymbols(identifier, scopes).every((namespaceSymbol) => + namespaceSymbol.references.every((reference) => { + const referenceRoot = findTransparentExpressionRoot(reference.identifier); + const member = referenceRoot.parent; + if (!isNodeOfType(member, "MemberExpression") || member.object !== referenceRoot) { + return ( + isNodeOfType(member, "VariableDeclarator") && + member.init === referenceRoot && + isNodeOfType(member.id, "Identifier") && + isNodeOfType(member.parent, "VariableDeclaration") && + member.parent.kind === "const" + ); + } + const referencedProperty = getStaticPropertyName(member); + return ( + (referencedProperty !== null && referencedProperty !== propertyName) || + isNonEscapingReactApiReference(member) + ); + }), + ); + } + return symbol.references.every((reference) => + isNonEscapingReactApiReference(reference.identifier), + ); +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.test.ts new file mode 100644 index 0000000000..6e160babf6 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import { + LOCAL_REACT_STATE_LARGE_CALL_COUNT, + LOCAL_REACT_STATE_MAXIMUM_LOOKUP_SCALING_MULTIPLIER, + LOCAL_REACT_STATE_SMALL_CALL_COUNT, +} from "../constants/thresholds.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isUnmodifiedLocalReactStateCall } from "./is-unmodified-local-react-state-call.js"; +import { parseSourceText } from "./parse-source-file.js"; +import { walkAst } from "./walk-ast.js"; + +interface LocalReactStateWork { + lookups: number; + inspectedProviderReferences: number; +} + +const providerWork = vi.hoisted(() => ({ inspectedReferences: 0 })); + +vi.mock("./is-non-escaping-react-api-reference.js", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + isNonEscapingReactApiReference: (expression: EsTreeNode): boolean => { + providerWork.inspectedReferences += 1; + return original.isNonEscapingReactApiReference(expression); + }, + }; +}); + +const prepare = (sourceText: string) => { + const program = parseSourceText({ filename: "local-state.tsx", sourceText }); + if (!program) throw new Error("Expected parsed source"); + const scopes = analyzeScopes(program); + const calls: EsTreeNode[] = []; + walkAst(program, (node) => { + if ( + isNodeOfType(node, "CallExpression") && + !(isNodeOfType(node.callee, "Identifier") && node.callee.name === "require") + ) + calls.push(node); + }); + return { program, scopes, calls }; +}; + +const measureLookups = (callCount: number, namespace: boolean): LocalReactStateWork => { + const source = namespace ? 'import React from "react";' : 'import {useState} from "react";'; + const callee = namespace ? "React.useState" : "useState"; + const { scopes, calls } = prepare( + source + + Array.from({ length: callCount }, (_, index) => `const stored${index} = ${callee}(0);`).join( + "\n", + ), + ); + providerWork.inspectedReferences = 0; + let lookups = 0; + const observedScopes = { + ...scopes, + symbolFor: (identifier: Parameters[0]) => { + lookups += 1; + return scopes.symbolFor(identifier); + }, + }; + expect(calls).toHaveLength(callCount); + for (const call of calls) + expect(isUnmodifiedLocalReactStateCall(call, observedScopes)).toBe(true); + return { lookups, inspectedProviderReferences: providerWork.inspectedReferences }; +}; + +describe("local React state provider facts", () => { + it.each([false, true])("bounds repeated provider work for namespace=$0", (namespace) => { + const small = measureLookups(LOCAL_REACT_STATE_SMALL_CALL_COUNT, namespace); + const large = measureLookups(LOCAL_REACT_STATE_LARGE_CALL_COUNT, namespace); + expect(small.lookups).toBeGreaterThan(0); + expect(small.inspectedProviderReferences).toBeGreaterThan(0); + expect(large.lookups).toBeLessThan( + small.lookups * LOCAL_REACT_STATE_MAXIMUM_LOOKUP_SCALING_MULTIPLIER, + ); + expect(large.inspectedProviderReferences).toBeLessThan( + small.inspectedProviderReferences * LOCAL_REACT_STATE_MAXIMUM_LOOKUP_SCALING_MULTIPLIER, + ); + }); + + it("keeps the allowed require node separate from imported call proofs", () => { + const { scopes, calls } = prepare( + 'import {useState} from "react";const React=require("react");const first=useState(0);const second=React.useState(1);', + ); + expect(calls).toHaveLength(2); + expect(isUnmodifiedLocalReactStateCall(calls[0], scopes)).toBe(false); + expect(isUnmodifiedLocalReactStateCall(calls[1], scopes)).toBe(true); + expect(isUnmodifiedLocalReactStateCall(calls[0], scopes)).toBe(false); + }); + + it("keeps initializer ordering outside the memoized provider proof", () => { + const { scopes, calls } = prepare( + 'React.useState(0);const React=require("react");React.useState(1);', + ); + expect(calls).toHaveLength(2); + expect(isUnmodifiedLocalReactStateCall(calls[0], scopes)).toBe(false); + expect(isUnmodifiedLocalReactStateCall(calls[1], scopes)).toBe(true); + }); + + it("does not reuse a valid provider proof for an unsupported call shape", () => { + const { scopes, calls } = prepare( + 'import React from "react";React.useState(0);React.namespace.useState(0);React.useRef(0);React["useState"](0);', + ); + expect(calls.map((call) => isUnmodifiedLocalReactStateCall(call, scopes))).toEqual([ + true, + false, + false, + true, + ]); + }); + + it("keeps numeric-data permission separate in both query orders", () => { + for (const firstMode of [false, true]) { + const { scopes, calls } = prepare( + 'import {useState} from "react";export const Panel=input=>{const [stored]=useState(7);const values=[3,7];const getKey=()=>Number(input);const total=values[getKey()];return {stored+total}};', + ); + const stateCall = calls.find( + (call) => + isNodeOfType(call, "CallExpression") && + isNodeOfType(call.callee, "Identifier") && + call.callee.name === "useState", + ); + if (!stateCall) throw new Error("Expected state call"); + for (const mode of [firstMode, !firstMode, firstMode]) { + expect( + isUnmodifiedLocalReactStateCall(stateCall, scopes, { allowNumericDataAccess: mode }), + ).toBe(mode); + } + } + }); + + it("does not carry a safe proof into a fresh hazardous program", () => { + for (const mutation of ["", "React.__defineGetter__('useState',()=>customState);", ""]) { + const { scopes, calls } = prepare( + 'import React from "react";' + mutation + "React.useState(0);", + ); + const stateCall = calls[calls.length - 1]; + expect(isUnmodifiedLocalReactStateCall(stateCall, scopes)).toBe(!mutation); + const freshScopes = analyzeScopes(scopes.rootScope.node); + expect(isUnmodifiedLocalReactStateCall(stateCall, freshScopes)).toBe(!mutation); + } + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts new file mode 100644 index 0000000000..a9c54bf382 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-unmodified-local-react-state-call.ts @@ -0,0 +1,305 @@ +import { CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT } from "../constants/react.js"; +import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { findTransparentExpressionRoot } from "./find-transparent-expression-root.js"; +import { getEquivalentSymbols } from "./get-equivalent-symbols.js"; +import { getGlobalRequireModuleSource } from "./get-global-require-module-source.js"; +import { getImportDeclarationForSymbol } from "./get-import-declaration-for-symbol.js"; +import { getRuntimeStaticDependencySource } from "./get-runtime-static-dependency-source.js"; +import { + isProvenGlobalNamespaceReference, + isProvenGlobalObjectReference, +} from "./is-proven-global-namespace-reference.js"; +import { getResolvedStaticPropertyName } from "./get-resolved-static-property-name.js"; +import { getRootIdentifier } from "./get-root-identifier.js"; +import { getStaticPropertyName } from "./get-static-property-name.js"; +import { hasReactDefaultProviderMutation } from "./has-react-default-provider-mutation.js"; +import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; +import { isBindingAssignmentTarget } from "./is-binding-assignment-target.js"; +import { hasRuntimeValueBinding } from "./has-runtime-value-binding.js"; +import { isErasedTypeScriptBinding } from "./is-erased-typescript-binding.js"; +import { isNumericDataMemberAccess } from "./is-numeric-data-member-access.js"; +import { isMemberMutationTarget } from "./is-member-mutation-target.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isNonEscapingReactApiReference } from "./is-non-escaping-react-api-reference.js"; +import { isReactApiCall } from "./is-react-api-call.js"; +import { isProvenUnmodifiedReactApiCall } from "./is-proven-unmodified-react-api-call.js"; +import { stripParenExpression } from "./strip-paren-expression.js"; +import { walkAst } from "./walk-ast.js"; + +const PROVIDER_GLOBAL_NAMES = new Set([ + "require", + "module", + "process", + "Function", + "eval", + "Object", + "Reflect", + "globalThis", + "global", + "window", + "self", +]); + +interface LocalReactStateCallOptions { + readonly allowNumericDataAccess?: boolean; +} + +interface LocalReactStateProgramFacts { + providerCalls: Map; + hasProviderMutation: boolean | null; + providerSymbolIds: Set | null; + providerHazards: Map>; +} + +const programFacts = new WeakMap>(); + +export const isUnmodifiedLocalReactStateCall = ( + call: EsTreeNode, + scopes: ScopeAnalysis, + options: LocalReactStateCallOptions = {}, +): boolean => { + if (!isNodeOfType(call, "CallExpression")) return false; + const callee = stripParenExpression(call.callee); + const receiver = getRootIdentifier(callee); + const symbol = receiver ? scopes.symbolFor(receiver) : null; + if (!receiver || !symbol) return false; + let scopeFacts = programFacts.get(scopes); + if (!scopeFacts) { + scopeFacts = new WeakMap(); + programFacts.set(scopes, scopeFacts); + } + const program = scopes.rootScope.node; + let facts = scopeFacts.get(program); + if (!facts) { + facts = { + providerCalls: new Map(), + hasProviderMutation: null, + providerSymbolIds: null, + providerHazards: new Map(), + }; + scopeFacts.set(program, facts); + } + const importDeclaration = getImportDeclarationForSymbol(symbol); + let allowedRequire: EsTreeNode | null = null; + if (importDeclaration) { + if ( + importDeclaration.importKind === "type" || + (isNodeOfType(symbol.declarationNode, "ImportSpecifier") && + symbol.declarationNode.importKind === "type") || + !isReactApiCall(call, "useState", scopes) + ) { + return false; + } + let validProvider = facts.providerCalls.get(symbol); + if (validProvider === undefined) { + validProvider = isProvenUnmodifiedReactApiCall(call, "useState", scopes); + facts.providerCalls.set(symbol, validProvider); + } + if (!validProvider) return false; + } else { + const declaration = symbol.declarationNode; + const initializer = symbol.initializer ? stripParenExpression(symbol.initializer) : null; + if ( + !isNodeOfType(callee, "MemberExpression") || + stripParenExpression(callee.object) !== receiver || + getStaticPropertyName(callee) !== "useState" || + symbol.kind !== "const" || + !isNodeOfType(declaration, "VariableDeclarator") || + declaration.id !== symbol.bindingIdentifier || + !isNodeOfType(initializer, "CallExpression") || + initializer.arguments.length !== CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT || + initializer.optional || + initializer.range[1] > call.range[0] || + getGlobalRequireModuleSource(initializer, scopes) !== "react" + ) { + return false; + } + let validProvider = facts.providerCalls.get(symbol); + if (validProvider === undefined) { + validProvider = !( + symbol.references.some((reference) => { + if (reference.flag !== "read" || isBindingAssignmentTarget(reference.identifier)) { + return true; + } + const referenceRoot = findTransparentExpressionRoot(reference.identifier); + const member = referenceRoot.parent; + if (!isNodeOfType(member, "MemberExpression") || member.object !== referenceRoot) { + return true; + } + const propertyName = getStaticPropertyName(member); + return ( + propertyName === null || + propertyName === "default" || + (propertyName === "useState" && !isNonEscapingReactApiReference(member)) + ); + }) || hasPossibleStaticPropertyMutationOrEscape(receiver, "useState", scopes) + ); + facts.providerCalls.set(symbol, validProvider); + } + if (!validProvider) return false; + allowedRequire = initializer; + } + if (facts.hasProviderMutation === null) { + facts.hasProviderMutation = hasReactDefaultProviderMutation("useState", scopes); + } + if (facts.hasProviderMutation) return false; + if (!facts.providerSymbolIds) { + facts.providerSymbolIds = new Set(); + for (const provider of scopes.rootScope.symbols) { + const declaration = getImportDeclarationForSymbol(provider); + if (declaration?.source.value !== "react") continue; + for (const equivalent of getEquivalentSymbols(provider.bindingIdentifier, scopes)) { + facts.providerSymbolIds.add(equivalent.id); + } + } + } + let providerHazards = facts.providerHazards.get(allowedRequire); + if (!providerHazards) { + providerHazards = new Map(); + facts.providerHazards.set(allowedRequire, providerHazards); + } + const allowNumericDataAccess = Boolean(options.allowNumericDataAccess); + const cachedHazard = providerHazards.get(allowNumericDataAccess); + if (cachedHazard !== undefined) return !cachedHazard; + const providerSymbolIds = new Set(facts.providerSymbolIds); + if (allowedRequire) providerSymbolIds.add(symbol.id); + let hasProviderHazard = false; + walkAst(scopes.rootScope.node, (child) => { + if (hasProviderHazard) return false; + if (isNodeOfType(child, "Identifier") && PROVIDER_GLOBAL_NAMES.has(child.name)) { + const binding = scopes.symbolFor(child); + if ( + binding && + scopes.referenceFor(child) && + isErasedTypeScriptBinding(binding) && + !hasRuntimeValueBinding(binding) + ) { + hasProviderHazard = true; + return false; + } + } + const dependency = + getRuntimeStaticDependencySource(child) ?? getGlobalRequireModuleSource(child, scopes); + if ( + dependency === "module" || + dependency === "node:module" || + isProvenGlobalNamespaceReference(child, "module", scopes) + ) { + hasProviderHazard = true; + } + if (isProvenGlobalNamespaceReference(child, "process", scopes)) { + const referenceRoot = findTransparentExpressionRoot(child); + const member = referenceRoot.parent; + if ( + !isNodeOfType(member, "MemberExpression") || + member.object !== referenceRoot || + getStaticPropertyName(member) !== "env" || + isMemberMutationTarget(member) + ) { + hasProviderHazard = true; + } + } + if ( + isNodeOfType(child, "WithStatement") || + isNodeOfType(child, "ImportExpression") || + isProvenGlobalNamespaceReference(child, "Function", scopes) || + isProvenGlobalNamespaceReference(child, "eval", scopes) || + isProvenGlobalNamespaceReference(child, "Object", scopes) || + isProvenGlobalNamespaceReference(child, "Reflect", scopes) + ) { + hasProviderHazard = true; + } + if (isProvenGlobalObjectReference(child, scopes)) { + const referenceRoot = findTransparentExpressionRoot(child); + const member = referenceRoot.parent; + const propertyName = isNodeOfType(member, "MemberExpression") + ? getResolvedStaticPropertyName(member, scopes) + : null; + if ( + propertyName === null || + propertyName === "Function" || + propertyName === "eval" || + propertyName === "Object" || + propertyName === "Reflect" || + propertyName === "module" || + propertyName === "process" || + propertyName === "require" + ) { + hasProviderHazard = true; + } + } + if ( + (isNodeOfType(child, "MemberExpression") || + (isNodeOfType(child, "Property") && isNodeOfType(child.parent, "ObjectPattern"))) && + child.computed && + getResolvedStaticPropertyName(child, scopes, { + allowConstNumericLiteral: true, + allowConstTemplateLiteral: true, + stringifyNonStringLiterals: true, + }) === null && + !(options.allowNumericDataAccess && isNumericDataMemberAccess(child, scopes)) + ) { + hasProviderHazard = true; + } + if ( + (isNodeOfType(child, "MemberExpression") || + (isNodeOfType(child, "Property") && isNodeOfType(child.parent, "ObjectPattern"))) && + getResolvedStaticPropertyName(child, scopes) === "constructor" + ) { + const constructorRoot = findTransparentExpressionRoot(child); + const metadata = constructorRoot.parent; + const metadataRoot = isNodeOfType(metadata, "MemberExpression") + ? findTransparentExpressionRoot(metadata) + : null; + const consumer = metadataRoot?.parent; + if ( + !isNodeOfType(child, "MemberExpression") || + !isNodeOfType(metadata, "MemberExpression") || + metadata.object !== constructorRoot || + (getStaticPropertyName(metadata) !== "name" && + getStaticPropertyName(metadata) !== "length") || + isMemberMutationTarget(metadata) || + (isNodeOfType(consumer, "CallExpression") && consumer.callee === metadataRoot) + ) { + hasProviderHazard = true; + } + } + if (isNodeOfType(child, "Identifier") && scopes.isGlobalReference(child)) { + if (child.name === "require") { + const referenceRoot = findTransparentExpressionRoot(child); + const invocation = referenceRoot.parent; + if ( + !isNodeOfType(invocation, "CallExpression") || + invocation.callee !== referenceRoot || + invocation.optional || + invocation.arguments.length !== CANONICAL_REACT_REQUIRE_ARGUMENT_COUNT || + !isNodeOfType(invocation.arguments[0], "Literal") || + typeof invocation.arguments[0].value !== "string" + ) { + hasProviderHazard = true; + } + } + } + if ( + isNodeOfType(child, "CallExpression") && + child !== allowedRequire && + getGlobalRequireModuleSource(child, scopes) === "react" + ) { + hasProviderHazard = true; + } + if (!isNodeOfType(child, "MemberExpression") || !isMemberMutationTarget(child)) return; + const identifier = getRootIdentifier(child.object); + const provider = identifier ? scopes.symbolFor(identifier) : null; + const propertyName = getStaticPropertyName(child); + if ( + provider && + providerSymbolIds.has(provider.id) && + (propertyName === null || propertyName === "useState") + ) { + hasProviderHazard = true; + } + }); + providerHazards.set(allowNumericDataAccess, hasProviderHazard); + return !hasProviderHazard; +}; diff --git a/packages/react-doctor/tests/constants.ts b/packages/react-doctor/tests/constants.ts index d9f948e18d..da33a0b890 100644 --- a/packages/react-doctor/tests/constants.ts +++ b/packages/react-doctor/tests/constants.ts @@ -2,3 +2,4 @@ export const PROMPT_PROBE_OBSERVATION_WINDOW_MS = 750; export const PROMPT_PROBE_STARTUP_TIMEOUT_MS = 10_000; export const PROMPT_PROBE_SLOW_START_MS = 1_500; export const TEMPORARY_DIRECTORY_REMOVE_MAX_RETRIES = 10; +export const CHANGED_SCOPE_COMPARISON_TEST_TIMEOUT_MS = 60_000; diff --git a/packages/react-doctor/tests/regressions/changed-scope-new-findings.test.ts b/packages/react-doctor/tests/regressions/changed-scope-new-findings.test.ts index 7903f0173a..d4975c5295 100644 --- a/packages/react-doctor/tests/regressions/changed-scope-new-findings.test.ts +++ b/packages/react-doctor/tests/regressions/changed-scope-new-findings.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import * as Schema from "effect/Schema"; import { JsonReport } from "@react-doctor/core/schemas"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { CHANGED_SCOPE_COMPARISON_TEST_TIMEOUT_MS } from "../constants.js"; import { commitAll, initGitRepo, writeFile, writeJson } from "./_helpers.js"; const CLI = path.resolve(import.meta.dirname, "../../bin/react-doctor.js"); @@ -251,33 +252,37 @@ describe("changed scope compares findings through the CLI", () => { it.each([ { name: "no added derived state", appendedSource: "" }, { name: "an added derived state", appendedSource: NEW_DERIVED_STATE }, - ])("keeps the HostedCart findings after reformatting with $name", ({ appendedSource }) => { - const sourcePath = path.join(directory, "src/HostedCart.tsx"); - writeFile(sourcePath, HOSTED_CART); - commitAll(directory, "base cart findings"); - const base = scan(directory); - expect(base.report.diagnostics.some((finding) => finding.rule === "exhaustive-deps")).toBe( - true, - ); - expect( - base.report.diagnostics.some( - (finding) => finding.rule === "no-set-state-after-await-in-effect", - ), - ).toBe(true); - writeJson(reportFile, base.report); - writeFile(sourcePath, REFORMATTED_HOSTED_CART + appendedSource); - const gitResult = scan(directory, ["--scope", "changed", "--base", "HEAD"]); - const savedResult = scan(directory, ["--scope", "changed", "--baseline", reportFile]); - const fullSavedResult = scan(directory, ["--baseline", reportFile]); - for (const result of [gitResult, savedResult, fullSavedResult]) { - expect(result.report.diagnostics.map((finding) => finding.rule)).toEqual( - appendedSource ? ["no-derived-state"] : [], + ])( + "keeps the HostedCart findings after reformatting with $name", + ({ appendedSource }) => { + const sourcePath = path.join(directory, "src/HostedCart.tsx"); + writeFile(sourcePath, HOSTED_CART); + commitAll(directory, "base cart findings"); + const base = scan(directory); + expect(base.report.diagnostics.some((finding) => finding.rule === "exhaustive-deps")).toBe( + true, ); - expect(result.report.summary.totalDiagnosticCount).toBe(appendedSource ? 1 : 0); - expect(result.status).toBe(appendedSource ? 1 : 0); - if (appendedSource) expect(result.report.diagnostics[0]?.line).toBe(15); - } - }); + expect( + base.report.diagnostics.some( + (finding) => finding.rule === "no-set-state-after-await-in-effect", + ), + ).toBe(true); + writeJson(reportFile, base.report); + writeFile(sourcePath, REFORMATTED_HOSTED_CART + appendedSource); + const gitResult = scan(directory, ["--scope", "changed", "--base", "HEAD"]); + const savedResult = scan(directory, ["--scope", "changed", "--baseline", reportFile]); + const fullSavedResult = scan(directory, ["--baseline", reportFile]); + for (const result of [gitResult, savedResult, fullSavedResult]) { + expect(result.report.diagnostics.map((finding) => finding.rule)).toEqual( + appendedSource ? ["no-derived-state"] : [], + ); + expect(result.report.summary.totalDiagnosticCount).toBe(appendedSource ? 1 : 0); + expect(result.status).toBe(appendedSource ? 1 : 0); + if (appendedSource) expect(result.report.diagnostics[0]?.line).toBe(15); + } + }, + CHANGED_SCOPE_COMPARISON_TEST_TIMEOUT_MS, + ); it.each([ { ignore: { rules: ["react-hooks/exhaustive-deps"] } },