diff --git a/.changeset/imported-call-static-values.md b/.changeset/imported-call-static-values.md new file mode 100644 index 0000000000..3f77be00f8 --- /dev/null +++ b/.changeset/imported-call-static-values.md @@ -0,0 +1,7 @@ +--- +"oxlint-plugin-react-doctor": patch +"eslint-plugin-react-doctor": patch +"react-doctor": patch +--- + +Avoid recommending module-scope hoisting for literals that evaluate imported functions, including readonly const aliases. This conservatively retains per-render evaluation without classifying imported functions as impure. 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/fuzz/corpus/regressions/prefer-module-scope-static-value--destructured-callee-alias-write.tsx b/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--destructured-callee-alias-write.tsx new file mode 100644 index 0000000000..56becfc90f --- /dev/null +++ b/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--destructured-callee-alias-write.tsx @@ -0,0 +1,11 @@ +// rule: prefer-module-scope-static-value +// verdict: fail +// weakness: cross-file +// source: independent synthetic imported-call boundary regression +import readSnapshot from "./state-reader"; +const selectedReader = readSnapshot; +({ selectedReader } = { selectedReader: () => "replacement" }); +export const useSettings = () => { + const options = { label: selectedReader() }; + return options; +}; diff --git a/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--imported-evaluation.tsx b/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--imported-evaluation.tsx new file mode 100644 index 0000000000..6b203ae467 --- /dev/null +++ b/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--imported-evaluation.tsx @@ -0,0 +1,13 @@ +// rule: prefer-module-scope-static-value +// verdict: pass +// weakness: cross-file +// source: independent synthetic imported evaluation control +import readSnapshot from "synthetic-snapshot-provider"; + +const firstReader = readSnapshot; +const selectedReader = firstReader; + +export const useSnapshotOptions = () => { + const options = { label: selectedReader() }; + return options; +}; diff --git a/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--merged-imported-reader-interface.tsx b/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--merged-imported-reader-interface.tsx new file mode 100644 index 0000000000..ddfb480aab --- /dev/null +++ b/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--merged-imported-reader-interface.tsx @@ -0,0 +1,12 @@ +// rule: prefer-module-scope-static-value +// verdict: pass +// weakness: cross-file +// source: independent synthetic imported-call boundary regression +import { useState } from "react"; +import readSnapshot from "./state-reader"; +interface readSnapshot {} +export const useSettings = () => { + const [enabled] = useState(true); + const options = { label: readSnapshot() }; + return { options, enabled }; +}; diff --git a/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--static-allocation-positive.tsx b/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--static-allocation-positive.tsx new file mode 100644 index 0000000000..c414096fbf --- /dev/null +++ b/packages/fuzz/corpus/regressions/prefer-module-scope-static-value--static-allocation-positive.tsx @@ -0,0 +1,9 @@ +// rule: prefer-module-scope-static-value +// verdict: fail +// weakness: cross-file +// source: independent synthetic static-allocation positive control + +export const StaticOptions = () => { + const options = { label: "fixed" }; + return {options.label}; +}; diff --git a/packages/fuzz/src/snippet-pools.ts b/packages/fuzz/src/snippet-pools.ts index accb163dd2..3d0e0a1290 100644 --- a/packages/fuzz/src/snippet-pools.ts +++ b/packages/fuzz/src/snippet-pools.ts @@ -314,6 +314,7 @@ export const LIBRARY_SNIPPET_POOL = [ // Module scope — SSR hazards, guard aliases, contexts, caches, styled. export const MODULE_SCOPE_SNIPPET_POOL = [ + `import { readSnapshot as fuzzReadImportedSnapshot } from "synthetic-snapshot-provider"; const fuzzSnapshotReader = fuzzReadImportedSnapshot; export const useFuzzImportedOptions = () => { const options = { label: fuzzSnapshotReader() }; return options; }; export const FuzzStaticOptionsControl = () => { const options = { label: "fixed" }; return {options.label}; };`, `import { create as createFuzzZustandStore } from "zustand"; export const useFuzzZustandStore = createFuzzZustandStore(() => ({ items: [] })); export const fuzzZustandSelection = useFuzzZustandStore((state) => ({ items: state.items }));`, `import { create as createFuzzZustandTarget } from "zustand"; const useFuzzZustandTarget = createFuzzZustandTarget(() => ({ value: 0, other: 0 })); export const FuzzZustandWholeStoreTarget = () => { const { value: fuzzZustandValue } = useFuzzZustandTarget(); return {fuzzZustandValue}; };`, `import { create as createFuzzZustandGetStore } from "zustand"; const useFuzzZustandGetStore = createFuzzZustandGetStore((_set, get) => ({ count: get().count }));`, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call-review.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call-review.test.ts new file mode 100644 index 0000000000..e727248b99 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call-review.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { preferModuleScopeStaticValue } from "./prefer-module-scope-static-value.js"; + +const cases = [ + { + name: "baseline-imported-evaluation", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + expected: 0, + }, + { + name: "merged-imported-reader-interface", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\ninterface readSnapshot {}\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + expected: 0, + }, + { + name: "merged-readonly-reader-alias-interface", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\ninterface selectedReader {}\nconst selectedReader = readSnapshot;\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: selectedReader() };\n return { options, enabled };\n};\n', + expected: 0, + }, + { + name: "merged-intermediate-reader-alias-interface", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\ninterface firstReader {}\nconst firstReader = readSnapshot;\nconst selectedReader = firstReader;\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: selectedReader() };\n return { options, enabled };\n};\n', + expected: 0, + }, + { + name: "genuine-local-reader-with-interface", + code: 'import { useState } from "react";\nconst readSnapshot = () => "fixed";\ninterface readSnapshot {}\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + expected: 1, + }, + { + name: "ordinary-local-reader-control", + code: 'import { useState } from "react";\nconst readSnapshot = () => "fixed";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + expected: 1, + }, + { + name: "type-only-reader-import-control", + code: 'import { useState } from "react";\nimport type readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + expected: 1, + }, + { + name: "static-object-activation", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: "fixed" };\n return { options, enabled };\n};\n', + expected: 1, + }, + { + name: "plain-const-alias-write", + code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\nselectedReader = () => "replacement";\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n', + expected: 1, + }, + { + name: "destructured-const-alias-write", + code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\n({ selectedReader } = { selectedReader: () => "replacement" });\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n', + expected: 1, + }, + { + name: "readonly-const-alias", + code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\n\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n', + expected: 0, + }, + { + name: "destructuring-rhs-readonly-alias", + code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\n\nconst holder: {reader?: unknown} = {};\n({reader: holder.reader} = {reader: selectedReader});\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n', + expected: 0, + }, + { + name: "destructuring-computed-key-readonly-alias", + code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\n\nlet ignored: unknown;\n({[selectedReader.name]: ignored} = {});\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n', + expected: 0, + }, +]; + +describe("imported call runtime binding and mutation boundaries", () => { + for (const testCase of cases) { + it(testCase.name, () => { + const result = runRule(preferModuleScopeStaticValue, testCase.code, { + filename: "src/imported-call-review.tsx", + includeLocations: true, + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(testCase.expected); + }); + } +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call.performance.test.ts new file mode 100644 index 0000000000..3cd1af6f36 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call.performance.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { SymbolDescriptor } from "../../semantic/scope-analysis.js"; +import { analyzeScopes } from "../../semantic/scope-analysis.js"; +import { attachParentReferences } from "../../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../../test-utils/parse-fixture.js"; +import { runRule } from "../../../test-utils/run-rule.js"; +import { + IMPORTED_CALL_AST_READS_PER_NODE, + IMPORTED_CALL_PROOF_COUNTS, + IMPORTED_CALL_REFERENCE_PASSES, +} from "../../../test-utils/imported-call-proof/constants.js"; +import { containsImportedValueCall } from "../../utils/contains-imported-value-call.js"; +import type { EsTreeNode } from "../../utils/es-tree-node.js"; +import { isNodeOfType } from "../../utils/is-node-of-type.js"; +import { walkAst } from "../../utils/walk-ast.js"; +import { preferModuleScopeStaticValue } from "./prefer-module-scope-static-value.js"; + +describe("imported call cold proof work", () => { + for (const directImport of [true, false]) { + for (const count of IMPORTED_CALL_PROOF_COUNTS) { + it(`${count} initializers sharing ${directImport ? "an import" : "a readonly alias chain"}`, () => { + const aliases = directImport + ? "" + : Array.from( + { length: count }, + (_, index) => + `const ${index === count - 1 ? "selectedReader" : `reader${index}`}=${index === 0 ? "readSnapshot" : `reader${index - 1}`};`, + ).join(""); + const callee = directImport ? "readSnapshot" : "selectedReader"; + const declarations = Array.from( + { length: count }, + (_, index) => `const options${index}={label:${callee}()};`, + ).join(""); + const returned = Array.from({ length: count }, (_, index) => `options${index}`).join(","); + const code = `import{useState}from'react';import readSnapshot from'./state-reader';${aliases}export const useSettings=()=>{const[enabled]=useState(true);${declarations}return{${returned},enabled}};`; + const rejectedCode = directImport + ? code.replace( + "import readSnapshot from'./state-reader';", + "const readSnapshot=()=> 'fixed';", + ) + : code.replace("const selectedReader=", "let selectedReader="); + const parsed = parseFixture(code); + attachParentReferences(parsed.program); + const scopes = analyzeScopes(parsed.program); + const nodes: EsTreeNode[] = []; + const initializers: EsTreeNode[] = []; + const symbols = new Set(); + walkAst(parsed.program, (node) => { + nodes.push(node); + if ( + isNodeOfType(node, "VariableDeclarator") && + isNodeOfType(node.id, "Identifier") && + node.id.name.startsWith("options") && + node.init + ) + initializers.push(node.init); + if (isNodeOfType(node, "Identifier")) { + const symbol = scopes.symbolFor(node); + if (symbol) symbols.add(symbol); + } + }); + let referenceVisits = 0; + let referenceCount = 0; + let astTypeReads = 0; + for (const symbol of symbols) { + const references = symbol.references; + referenceCount += references.length; + Object.defineProperty(symbol, "references", { + configurable: true, + value: new Proxy(references, { + get(target, property, receiver) { + if (typeof property === "string" && /^\d+$/.test(property)) referenceVisits++; + return Reflect.get(target, property, receiver); + }, + }), + }); + } + for (const node of nodes) { + const nodeType = node.type; + Object.defineProperty(node, "type", { + configurable: true, + enumerable: true, + get: () => { + astTypeReads++; + return nodeType; + }, + }); + } + expect(parsed.errors).toEqual([]); + expect( + initializers.map((initializer) => containsImportedValueCall(initializer, scopes)), + ).toEqual(Array.from({ length: count }, () => true)); + expect(referenceVisits).toBeLessThanOrEqual( + referenceCount * IMPORTED_CALL_REFERENCE_PASSES, + ); + expect(astTypeReads).toBeLessThanOrEqual(nodes.length * IMPORTED_CALL_AST_READS_PER_NODE); + const options = { filename: "src/imported-call-work.tsx", includeLocations: true }; + const actual = runRule(preferModuleScopeStaticValue, code, options); + const rejected = runRule(preferModuleScopeStaticValue, rejectedCode, options); + expect(actual.parseErrors).toEqual([]); + expect(actual.diagnostics).toHaveLength(0); + expect(rejected.parseErrors).toEqual([]); + expect(rejected.diagnostics).toHaveLength(count); + }); + } + } +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call.test.ts new file mode 100644 index 0000000000..a7fb7807b2 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value-imported-call.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from "vite-plus/test"; +import { runRule, type RunRuleOptions } from "../../../test-utils/run-rule.js"; +import { preferModuleScopeStaticValue } from "./prefer-module-scope-static-value.js"; + +interface ImportedValueCallCase { + name: string; + code: string; + options: RunRuleOptions; + expectedCount: number; +} + +const cases: ReadonlyArray = [ + { + name: "imported-default-state-reader", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/imported-default-state-reader.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "imported-named-state-reader", + code: 'import { useState } from "react";\nimport { readSnapshot } from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/imported-named-state-reader.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "renamed-reader-import", + code: 'import { useState } from "react";\nimport { readSnapshot as selectLabel } from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: selectLabel() };\n return { options, enabled };\n};\n', + options: { + filename: "src/renamed-reader-import.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "nested-imported-reader-result", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { detail: { label: readSnapshot() } };\n return { options, enabled };\n};\n', + options: { + filename: "src/nested-imported-reader-result.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "array-imported-reader-result", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = [readSnapshot()];\n return { options, enabled };\n};\n', + options: { + filename: "src/array-imported-reader-result.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "readonly-import-callee-alias", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nconst selectLabel = readSnapshot;\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: selectLabel() };\n return { options, enabled };\n};\n', + options: { + filename: "src/readonly-import-callee-alias.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "namespace-static-reader-call", + code: 'import { useState } from "react";\nimport * as state from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: state.readSnapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/namespace-static-reader-call.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "literal-only-object", + code: 'import { useState } from "react";\n\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: "ready" };\n return { options, enabled };\n};\n', + options: { + filename: "src/literal-only-object.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "literal-only-array", + code: 'import { useState } from "react";\n\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = ["ready", "waiting"];\n return { options, enabled };\n};\n', + options: { + filename: "src/literal-only-array.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "imported-constant-value-not-call", + code: 'import { useState } from "react";\nimport { stableLabel } from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: stableLabel };\n return { options, enabled };\n};\n', + options: { + filename: "src/imported-constant-value-not-call.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "inline-function-initializer", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { read: () => readSnapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/inline-function-initializer.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "known-date-now-call", + code: 'import { useState } from "react";\n\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { value: Date.now() };\n return { options, enabled };\n};\n', + options: { + filename: "src/known-date-now-call.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "known-random-call", + code: 'import { useState } from "react";\n\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { value: Math.random() };\n return { options, enabled };\n};\n', + options: { + filename: "src/known-random-call.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "literal-native-math-call", + code: 'import { useState } from "react";\n\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { value: Math.max(1, 2) };\n return { options, enabled };\n};\n', + options: { + filename: "src/literal-native-math-call.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "pure-local-name-shadow", + code: 'import { useState } from "react";\nimport { readSnapshot as importedReader } from "./state-reader";\nconst readSnapshot = () => "fixed";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/pure-local-name-shadow.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "nested-event-allocation", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const Panel = () => { const click = () => { const options = { label: readSnapshot() }; console.log(options); }; return ; };\n', + options: { + filename: "src/nested-event-allocation.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "actually-pure-import-still-conservatively-abstains", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/actually-pure-import-still-conservatively-abstains.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "imported-function-value-without-evaluation", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { read: readSnapshot };\n return { options, enabled };\n};\n', + options: { + filename: "src/imported-function-value-without-evaluation.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "two-readonly-module-callee-aliases", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nconst firstReader = (readSnapshot);\nconst secondReader = firstReader;\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: secondReader() };\n return { options, enabled };\n};\n', + options: { + filename: "src/two-readonly-module-callee-aliases.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "type-only-import-is-not-runtime-value-evidence", + code: 'import { useState } from "react";\nimport type readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: readSnapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/type-only-import-is-not-runtime-value-evidence.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "imported-constructor-outside-bare-call-v1", + code: 'import { useState } from "react";\nimport Snapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { value: new Snapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/imported-constructor-outside-bare-call-v1.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "statically-dead-imported-call-conservative-policy", + code: 'import { useState } from "react";\nimport readSnapshot from "./state-reader";\nexport const useSettings = () => {\n const [enabled] = useState(true);\n const options = { label: false && readSnapshot() };\n return { options, enabled };\n};\n', + options: { + filename: "src/statically-dead-imported-call-conservative-policy.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "inline-type-only-specifier-is-not-value-evidence", + code: 'import { type readSnapshot } from "./state-reader";\n\nexport const useSettings = () => {\n const options = { label: readSnapshot() };\n return options;\n};\n', + options: { + filename: "src/inline-type-only-specifier-is-not-value-evidence.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "bare-namespace-import-is-outside-call-policy", + code: 'import * as state from "./state-reader";\n\nexport const useSettings = () => {\n const options = { label: state() };\n return options;\n};\n', + options: { + filename: "src/bare-namespace-import-is-outside-call-policy.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "module-let-callee-alias-is-not-const", + code: 'import readSnapshot from "./state-reader";\nlet selectedReader = readSnapshot;\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n', + options: { + filename: "src/module-let-callee-alias-is-not-const.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "forward-reference-alias-initializer-declines", + code: 'import readSnapshot from "./state-reader";\nconst firstReader = secondReader;\nconst secondReader = readSnapshot;\nexport const useSettings = () => {\n const options = { label: firstReader() };\n return options;\n};\n', + options: { + filename: "src/forward-reference-alias-initializer-declines.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "cyclic-const-aliases-decline", + code: "\nconst firstReader = secondReader;\nconst secondReader = firstReader;\nexport const useSettings = () => {\n const options = { label: firstReader() };\n return options;\n};\n", + options: { + filename: "src/cyclic-const-aliases-decline.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "transparent-readonly-callee-aliases", + code: 'import readSnapshot from "./state-reader";\nconst firstReader = (readSnapshot as () => string);\nconst secondReader = firstReader!;\nexport const useSettings = () => {\n const options = { label: (secondReader)() };\n return options;\n};\n', + options: { + filename: "src/transparent-readonly-callee-aliases.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "named-default-value-import", + code: 'import { default as readSnapshot } from "./state-reader";\n\nexport const useSettings = () => {\n const options = { label: readSnapshot() };\n return options;\n};\n', + options: { + filename: "src/named-default-value-import.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "optional-imported-call-remains-outside-v1", + code: 'import readSnapshot from "./state-reader";\n\nexport const useSettings = () => {\n const options = { label: readSnapshot?.() };\n return options;\n};\n', + options: { + filename: "src/optional-imported-call-remains-outside-v1.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "tagged-imported-call-remains-outside-v1", + code: 'import readSnapshot from "./state-reader";\n\nexport const useSettings = () => {\n const options = { label: readSnapshot`label` };\n return options;\n};\n', + options: { + filename: "src/tagged-imported-call-remains-outside-v1.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "module-local-callee-shadow-is-not-import", + code: 'import { readSnapshot as providerReader } from "./state-reader";\nconst readSnapshot = () => "fixed";\nexport const useSettings = () => {\n const options = { label: readSnapshot() };\n return options;\n};\n', + options: { + filename: "src/module-local-callee-shadow-is-not-import.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, + { + name: "runtime-import-declaration-is-hoisted", + code: '\n\nexport const useSettings = () => {\n const options = { label: readSnapshot() };\n return options;\n};\nimport readSnapshot from "./state-reader";\n', + options: { + filename: "src/runtime-import-declaration-is-hoisted.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 0, + }, + { + name: "const-alias-write-reference-declines", + code: 'import readSnapshot from "./state-reader";\nconst selectedReader = readSnapshot;\nselectedReader = () => "replacement";\nexport const useSettings = () => {\n const options = { label: selectedReader() };\n return options;\n};\n', + options: { + filename: "src/const-alias-write-reference-declines.tsx", + forceJsx: false, + includeLocations: true, + settings: { "react-doctor": { portedRuleMode: "curated", capabilities: ["react"] } }, + }, + expectedCount: 1, + }, +]; + +describe("imported value calls in static initializers", () => { + for (const testCase of cases) { + it(testCase.name, () => { + const result = runRule(preferModuleScopeStaticValue, testCase.code, testCase.options); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(testCase.expectedCount); + }); + } +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value.ts index a3912779ff..14f0ecac87 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/prefer-module-scope-static-value.ts @@ -1,4 +1,5 @@ import { MUTATING_ARRAY_METHODS, MUTATING_COLLECTION_METHODS } from "../../constants/js.js"; +import { containsImportedValueCall } from "../../utils/contains-imported-value-call.js"; import { defineRule } from "../../utils/define-rule.js"; import { enclosingComponentOrHookScope } from "../../utils/enclosing-component-or-hook-scope.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; @@ -343,6 +344,7 @@ export const preferModuleScopeStaticValue = defineRule({ // to recompute each render; hoisting would freeze them to a single // module-load value and change observable behavior. if (containsImpureExpression(initializer, context.scopes)) return; + if (containsImportedValueCall(initializer, context.scopes)) return; // A binding consumed ONLY as the receiver of scalar lookups // (`KEYS.includes(k)`) never escapes as a value — identity can't // break memoized children, so the hoist advice is pure noise. 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/contains-imported-value-call.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/contains-imported-value-call.test.ts new file mode 100644 index 0000000000..96e8c8412b --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/contains-imported-value-call.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import { containsImportedValueCall } from "./contains-imported-value-call.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { walkAst } from "./walk-ast.js"; + +describe("imported alias origin cache declaration order", () => { + for (const reverse of [false, true]) { + it(`keeps per-use order after ${reverse ? "late" : "early"} use warms the origin`, () => { + const parsed = parseFixture(`import readSnapshot from './reader'; + const early = {value: selectedReader()}; + const selectedReader = readSnapshot; + const late = {value: selectedReader()};`); + attachParentReferences(parsed.program); + const scopes = analyzeScopes(parsed.program); + const initializers: EsTreeNode[] = []; + walkAst(parsed.program, (node) => { + if ( + isNodeOfType(node, "VariableDeclarator") && + isNodeOfType(node.id, "Identifier") && + (node.id.name === "early" || node.id.name === "late") && + node.init + ) + initializers.push(node.init); + }); + expect(parsed.errors).toEqual([]); + expect(initializers).toHaveLength(2); + const ordered = reverse ? [...initializers].reverse() : initializers; + const expected = reverse ? [true, false] : [false, true]; + expect(ordered.map((expression) => containsImportedValueCall(expression, scopes))).toEqual( + expected, + ); + expect( + [...ordered].reverse().map((expression) => containsImportedValueCall(expression, scopes)), + ).toEqual([...expected].reverse()); + expect(ordered.map((expression) => containsImportedValueCall(expression, scopes))).toEqual( + expected, + ); + }); + } +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/contains-imported-value-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/contains-imported-value-call.ts new file mode 100644 index 0000000000..d49bea0d3b --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/contains-imported-value-call.ts @@ -0,0 +1,73 @@ +import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import { getDirectConstInitializer } from "./get-direct-const-initializer.js"; +import { getValueImportDeclaration } from "./get-value-import-declaration.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"; + +const readonlySymbols = new WeakMap(); +const importedOrigins = new WeakMap(); + +const getReadonlyCalleeSymbol = ( + callee: EsTreeNode, + scopes: ScopeAnalysis, +): SymbolDescriptor | null => { + if (!isNodeOfType(callee, "Identifier")) return null; + const reference = scopes.referenceFor(callee); + const symbol = reference?.resolvedSymbol; + if (!symbol || reference.flag !== "read") return null; + let isReadonly = readonlySymbols.get(symbol); + if (isReadonly === undefined) { + isReadonly = symbol.references.every( + (candidateReference) => + candidateReference.flag === "read" && + !isMemberMutationTarget(candidateReference.identifier), + ); + readonlySymbols.set(symbol, isReadonly); + } + if (!isReadonly) return null; + if (getValueImportDeclaration(symbol)) return symbol; + return getDirectConstInitializer(symbol) && symbol.declarationNode.range[1] <= callee.range[0] + ? symbol + : null; +}; + +const hasImportedOrigin = (symbol: SymbolDescriptor, scopes: ScopeAnalysis): boolean => { + const visitedSymbols = new Set(); + let currentSymbol: SymbolDescriptor | null = symbol; + let hasValueImport = false; + while (currentSymbol && !visitedSymbols.has(currentSymbol)) { + const cachedOrigin = importedOrigins.get(currentSymbol); + if (cachedOrigin !== undefined) { + hasValueImport = cachedOrigin; + break; + } + visitedSymbols.add(currentSymbol); + if (getValueImportDeclaration(currentSymbol)) { + hasValueImport = !isNodeOfType(currentSymbol.declarationNode, "ImportNamespaceSpecifier"); + break; + } + const initializer = getDirectConstInitializer(currentSymbol); + currentSymbol = initializer + ? getReadonlyCalleeSymbol(stripParenExpression(initializer), scopes) + : null; + } + for (const visitedSymbol of visitedSymbols) importedOrigins.set(visitedSymbol, hasValueImport); + return hasValueImport; +}; + +export const containsImportedValueCall = ( + expression: EsTreeNode, + scopes: ScopeAnalysis, +): boolean => { + let foundImportedCall = false; + walkAst(expression, (node) => { + if (foundImportedCall) return false; + if (!isNodeOfType(node, "CallExpression") || node.optional) return; + const symbol = getReadonlyCalleeSymbol(stripParenExpression(node.callee), scopes); + if (symbol) foundImportedCall = hasImportedOrigin(symbol, scopes); + }); + return foundImportedCall; +}; 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-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/test-utils/imported-call-proof/constants.ts b/packages/oxlint-plugin-react-doctor/src/test-utils/imported-call-proof/constants.ts new file mode 100644 index 0000000000..10c577c84b --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/test-utils/imported-call-proof/constants.ts @@ -0,0 +1,3 @@ +export const IMPORTED_CALL_PROOF_COUNTS = [20, 100]; +export const IMPORTED_CALL_AST_READS_PER_NODE = 64; +export const IMPORTED_CALL_REFERENCE_PASSES = 2;