diff --git a/.changeset/catch-native-storage-user-read-rejections.md b/.changeset/catch-native-storage-user-read-rejections.md new file mode 100644 index 0000000000..192fbeacff --- /dev/null +++ b/.changeset/catch-native-storage-user-read-rejections.md @@ -0,0 +1,7 @@ +--- +"react-doctor": patch +"oxlint-plugin-react-doctor": patch +"eslint-plugin-react-doctor": patch +--- + +Detect missing rejection handlers for direct OIDC user reads backed by native browser storage, including one immutable local facade. Cache provider proofs across repeated reads while preserving their filesystem dependencies. diff --git a/packages/core/tests/cross-file-rule-ids.test.ts b/packages/core/tests/cross-file-rule-ids.test.ts index 58e964d96b..6e3b938ac0 100644 --- a/packages/core/tests/cross-file-rule-ids.test.ts +++ b/packages/core/tests/cross-file-rule-ids.test.ts @@ -189,6 +189,7 @@ describe("CROSS_FILE_RULE_IDS", () => { "no-locale-format-in-render", "no-match-media-in-state-initializer", "no-mutating-reducer-state", + "no-promise-then-side-effect-in-effect-without-catch", "no-reset-all-state-on-prop-change", "no-side-effect-in-state-updater-function", "no-unguarded-browser-global-at-module-scope", diff --git a/packages/fuzz/corpus/true-positives/no-promise-then-side-effect-in-effect-without-catch--oidc-native-storage.tsx b/packages/fuzz/corpus/true-positives/no-promise-then-side-effect-in-effect-without-catch--oidc-native-storage.tsx new file mode 100644 index 0000000000..84db72ff03 --- /dev/null +++ b/packages/fuzz/corpus/true-positives/no-promise-then-side-effect-in-effect-without-catch--oidc-native-storage.tsx @@ -0,0 +1,15 @@ +// rule: no-promise-then-side-effect-in-effect-without-catch +// weakness: cross-file +// source: SDK promise role regression +// verdict: fail +import { useEffect, useState } from "react"; +import { UserManager, WebStorageStateStore } from "oidc-client"; +const settings = { userStore: new WebStorageStateStore({ store: window.localStorage }) }; +const manager = new UserManager(settings); +export const AccountPanel = () => { + const [user, setUser] = useState(null); + useEffect(() => { + manager.getUser().then(setUser); + }, []); + return {user?.name}; +}; diff --git a/packages/fuzz/src/snippet-pools.ts b/packages/fuzz/src/snippet-pools.ts index 2bb6329af9..112c01e598 100644 --- a/packages/fuzz/src/snippet-pools.ts +++ b/packages/fuzz/src/snippet-pools.ts @@ -333,6 +333,7 @@ export const LIBRARY_SNIPPET_POOL = [ // Module scope — SSR hazards, guard aliases, contexts, caches, styled. export const MODULE_SCOPE_SNIPPET_POOL = [ + `import {useEffect as useAccountEffect,useState as useAccountState} from "react"; import {UserManager,WebStorageStateStore} from "oidc-client"; const accountSettings={userStore:new WebStorageStateStore({store:window.localStorage})}; const accountManager=new UserManager(accountSettings); export const FuzzAccountUser=()=>{const [user,setUser]=useAccountState(null);useAccountEffect(()=>{accountManager.getUser().then(setUser)},[]);return {user?.name}};`, `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/constants/cross-file-rule-ids.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/cross-file-rule-ids.ts index 6a27227068..599580bcd4 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/cross-file-rule-ids.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/cross-file-rule-ids.ts @@ -84,6 +84,7 @@ export const CROSS_FILE_RULE_IDS: ReadonlySet = new Set([ "remotion-no-next-image", "rerender-memo-with-default-value", "rules-of-hooks", + "no-promise-then-side-effect-in-effect-without-catch", "server-sequential-independent-await", "rn-no-deprecated-modules", "rn-no-legacy-shadow-styles", 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 b20f5bc817..43e5ef9581 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/react.ts @@ -332,3 +332,51 @@ export const REACT_PROVIDER_MUTATION_CAPABILITY_NAMES = new Set([ "__proto__", "prototype", ]); + +export const OIDC_CLIENT_MODULE_SOURCE = "oidc-client"; +export const OIDC_GET_USER_ARGUMENT_COUNT = 0; +export const OIDC_CONSTRUCTOR_ARGUMENT_COUNT = 1; +export const OIDC_WINDOW_DECODER_ARGUMENT_COUNT = 1; +export const OIDC_CONSTRUCTOR_ARGUMENT_INDEX = 0; +export const OIDC_STORE_OPTION_PROPERTY_COUNT = 1; +export const OIDC_INSTANCE_METHOD_NAMES = new Set(["getUser", "signinRedirect", "signoutRedirect"]); +export const OIDC_UNSUPPORTED_CAPABILITY_NAMES = new Set([ + "eval", + "Function", + "require", + "module", + "exports", + "globalThis", + "global", + "self", + "Object", + "Reflect", + "Proxy", + "document", + "parent", + "top", + "frames", + "localStorage", + "sessionStorage", +]); +export const OIDC_UNSUPPORTED_MEMBER_NAMES = new Set([ + "constructor", + "prototype", + "__proto__", + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "defineProperty", + "defineProperties", + "setPrototypeOf", +]); +export const OIDC_UNSUPPORTED_RUNTIME_TYPES = new Set([ + "WithStatement", + "ImportExpression", + "TSEnumDeclaration", + "TSModuleDeclaration", + "TSParameterProperty", + "TSImportEqualsDeclaration", + "TSExportAssignment", +]); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts index 353de702f8..6be9a1b027 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/cross-file-dependencies.ts @@ -1,3 +1,4 @@ +import { isRejectableOidcUserManagerCall } from "./utils/is-rejectable-oidc-user-manager-call.js"; import { resolveImportedApiReference } from "./utils/resolve-imported-api-reference.js"; import { readNearestPackageManifest } from "./utils/read-nearest-package-manifest.js"; import { resolvePackageVersion } from "./utils/resolve-package-version.js"; @@ -573,6 +574,20 @@ const collectInkNoRawTextDependencies: CrossFileDependencyCollector = (input) => }); }; +const collectOidcUserManagerDependencies: CrossFileDependencyCollector = ({ + absoluteFilePath, + getProgram, +}) => { + const program = getProgram(); + attachParentReferences(program); + const scopes = analyzeScopes(program); + walkAst(program, (node) => { + if (isNodeOfType(node, "CallExpression")) { + isRejectableOidcUserManagerCall(node, { filename: absoluteFilePath, scopes }); + } + }); +}; + export const CROSS_FILE_DEPENDENCY_COLLECTORS: ReadonlyMap = new Map([ ...INK_RULE_IDS.map((ruleId): [string, CrossFileDependencyCollector] => [ @@ -585,6 +600,7 @@ export const CROSS_FILE_DEPENDENCY_COLLECTORS: ReadonlyMap { @@ -14,4 +18,31 @@ describe("react-doctor plugin filesystem-cache reset hook", () => { Symbol.for("react-doctor.reset-filesystem-caches"), ); }); + + it("refreshes same-size cross-file source after the filesystem epoch reset", () => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "rd-parse-epoch-")); + const providerPath = path.join(temporaryDirectory, "provider.ts"); + const preservedTimestamp = new Date("2025-01-01T00:00:00.000Z"); + try { + fs.writeFileSync(providerPath, '"string";'); + fs.utimesSync(providerPath, preservedTimestamp, preservedTimestamp); + expect(parseSourceFile(providerPath)).toMatchObject({ + type: "Program", + body: [{ type: "ExpressionStatement", expression: { type: "Literal", value: "string" } }], + }); + + fs.writeFileSync(providerPath, '"number";'); + fs.utimesSync(providerPath, preservedTimestamp, preservedTimestamp); + expect(fs.statSync(providerPath).mtimeMs).toBe(preservedTimestamp.getTime()); + expect(fs.statSync(providerPath).size).toBe(Buffer.byteLength('"string";')); + resetFilesystemCaches(); + expect(parseSourceFile(providerPath)).toMatchObject({ + type: "Program", + body: [{ type: "ExpressionStatement", expression: { type: "Literal", value: "number" } }], + }); + } finally { + resetFilesystemCaches(); + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-promise-then-side-effect-in-effect-without-catch.oidc.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-promise-then-side-effect-in-effect-without-catch.oidc.test.ts new file mode 100644 index 0000000000..ef8866b752 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-promise-then-side-effect-in-effect-without-catch.oidc.test.ts @@ -0,0 +1,984 @@ +import { + PROVIDER_PROOF_SMALL_COMPONENT_COUNT, + PROVIDER_PROOF_LARGE_COMPONENT_COUNT, + PROVIDER_PROOF_MAX_GROWTH_RATIO, +} from "../../../test-utils/constants.js"; +import { captureCrossFileProbes } from "../../utils/cross-file-probe-recorder.js"; +import { resetFilesystemCaches } from "../../utils/reset-filesystem-caches.js"; +import * as fs from "node:fs"; +import os from "node:os"; +import * as path from "node:path"; +import { afterAll, describe, expect, it } from "vite-plus/test"; +import { runRule } from "../../../test-utils/run-rule.js"; +import type { Rule } from "../../utils/rule.js"; +import { isRejectableOidcUserManagerCall } from "../../utils/is-rejectable-oidc-user-manager-call.js"; +import { collectCrossFileDependencyProbes } from "../../cross-file-dependencies.js"; +import { __clearParseSourceFileCacheForTests } from "../../utils/parse-source-file.js"; +import { resetCrossFileExportCaches } from "../../utils/resolve-cross-file-function-export.js"; +import { resetModuleResolutionCaches } from "../../utils/resolve-relative-import-path.js"; +import { resetTsconfigAliasCaches } from "../../utils/resolve-tsconfig-alias.js"; +import { noPromiseThenSideEffectInEffectWithoutCatch } from "./no-promise-then-side-effect-in-effect-without-catch.js"; + +const certificateProbe: Rule = { + id: "oidc-get-user-certificate-probe", + severity: "warn", + create: (context) => ({ + CallExpression: (node) => { + if (isRejectableOidcUserManagerCall(node, context)) + context.report({ node, message: "native store getter" }); + }, + }), +}; +const cases = [ + { + name: "same-file native local storage", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: true, + diagnostics: 1, + }, + { + name: "same-file native session storage", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.sessionStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: true, + diagnostics: 1, + }, + { + name: "renamed SDK constructor imports", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager as Manager,WebStorageStateStore as Store} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new Store({store:window.localStorage})};\nconst manager=new Manager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: true, + diagnostics: 1, + }, + { + name: "inline settings", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\n\nconst manager=new UserManager({userStore:new WebStorageStateStore({store:window.localStorage})});\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: true, + diagnostics: 1, + }, + { + name: "ordinary provider method siblings", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconst enter=()=>manager.signinRedirect();const leave=()=>manager.signoutRedirect();\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: true, + diagnostics: 1, + }, + { + name: "existing catch remains quiet", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser).catch(()=>setUser(null));},[]);return {user?.name}};', + certificate: true, + diagnostics: 0, + }, + { + name: "native-shaped interface type unrelated", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\ninterface Account {name:string}\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: true, + diagnostics: 1, + }, + { + name: "wrong SDK module", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "other-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "different SDK generation", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client-ts";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "type-only SDK constructor", + source: + 'import {useEffect,useState} from "react";\nimport type {UserManager} from "oidc-client";import {WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "type-only store constructor", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,type WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "default SDK constructor", + source: + 'import {useEffect,useState} from "react";\nimport UserManager,{WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "local constructor homonym", + source: + 'import {useEffect,useState} from "react";\nimport {WebStorageStateStore} from "oidc-client";class UserManager {getUser(){return Promise.resolve(null)}}\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "constructor alias", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst Manager=UserManager;const manager=new Manager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "subclass", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nclass Manager extends UserManager{}const manager=new Manager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "custom store", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={userStore:{get:()=>Promise.resolve(null)}};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "unknown store factory", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={userStore:createStore()};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "custom storage object", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={userStore:new WebStorageStateStore({store:{getItem:()=>null}})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "unproven default settings", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\n\nconst manager=new UserManager();\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "store prefix transport", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={userStore:new WebStorageStateStore({store:window.localStorage,prefix:customPrefix})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "native storage shadow", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconst window={localStorage:{getItem:()=>null}};\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "native storage alias mutation", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconst store=window.localStorage;store.getItem=()=>null;\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "bare native storage mutation", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nlocalStorage.getItem=()=>null;\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "native storage replacement", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nwindow.localStorage={getItem:()=>null};\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "storage prototype override", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nStorage.prototype.getItem=()=>null;\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "second store constructor exposure", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconst other=new WebStorageStateStore({store:window.localStorage});other._store.getItem=()=>null;\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "settings spread", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={...base,userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "settings duplicate override", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={userStore:new WebStorageStateStore({store:window.localStorage}),userStore:customStore};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "settings accessor", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={get userStore(){return new WebStorageStateStore({store:window.localStorage})}};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "settings assignment", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nsettings.userStore=customStore;\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "settings escape", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconfigure(settings);\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "instance member override", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nmanager.getUser=()=>Promise.resolve(null);\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "instance prototype override", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nUserManager.prototype.getUser=()=>Promise.resolve(null);\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "instance accessor override", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nmanager.__defineGetter__("getUser",()=>()=>Promise.resolve(null));\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "instance escape", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconfigure(manager);\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "getter extraction", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconst getUser=manager.getUser;\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "instance binding write", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n({manager}=replacement);\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "optional instance access", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager?.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "optional getter call", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser?.().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "computed getter", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager["getUser"]().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "getter arguments", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser(1).then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "dynamic loader", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconst patch=()=>import("./patch");\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "direct eval", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconst patch=()=>eval("replaceStore()");\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "runtime TypeScript enum", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nenum Runtime {Value=replaceStore()}\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "runtime TypeScript parameter property", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nclass Runtime {constructor(public value=replaceStore()) {}}\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "Promise native override", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nPromise.resolve=()=>({then:()=>null});\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "JSON native override", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nJSON.parse=()=>null;\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "global alias escape", + source: + 'import {useEffect,useState} from "react";\nimport {UserManager,WebStorageStateStore} from "oidc-client";\nconst settings={authority:"https://issuer.invalid",client_id:"example",userStore:new WebStorageStateStore({store:window.localStorage})};\nconst manager=new UserManager(settings);\nconst browser=window;\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 0, + }, + { + name: "generic fetch path retained", + source: + 'import {useEffect,useState} from "react";\n\n\n\n\nexport const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{fetch("/account").then(setUser);},[]);return {user?.name}};', + certificate: false, + diagnostics: 1, + }, +]; +const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-oidc-get-user-")); +afterAll(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); +const resetLookups = () => { + __clearParseSourceFileCacheForTests(); + resetCrossFileExportCaches(); + resetModuleResolutionCaches(); + resetTsconfigAliasCaches(); +}; + +describe("OIDC native storage promise initiators", () => { + for (const testCase of cases) { + it(testCase.name, () => { + const probe = runRule(certificateProbe, testCase.source); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, testCase.source); + expect(probe.parseErrors).toEqual([]); + expect(result.parseErrors).toEqual([]); + expect(probe.diagnostics).toHaveLength(testCase.certificate ? 1 : 0); + expect(result.diagnostics).toHaveLength(testCase.diagnostics); + }); + } + it("declines a host without a filename", () => { + const result = runRule(certificateProbe, cases[0].source, { filename: undefined }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toEqual([]); + }); +}); + +const facadeSource = `import {UserManager,WebStorageStateStore} from "oidc-client"; +const settings={userStore:new WebStorageStateStore({store:window.localStorage})}; +export const manager=new UserManager(settings); +`; +const consumerSource = `import {useEffect,useState} from "react"; +import {manager} from "./session"; +export const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser)},[]);return {user?.name}}; +`; + +describe("OIDC direct exported facade", () => { + for (const testCase of [ + { name: "direct facade", provider: facadeSource, consumer: consumerSource, count: 1 }, + { + name: "provider method override", + provider: facadeSource + "manager.getUser=()=>Promise.resolve(null);", + consumer: consumerSource, + count: 0, + }, + { + name: "consumer method override", + provider: facadeSource, + consumer: consumerSource + "manager.getUser=()=>Promise.resolve(null);", + count: 0, + }, + { + name: "provider escape", + provider: facadeSource + "configure(manager);", + consumer: consumerSource, + count: 0, + }, + { + name: "consumer escape", + provider: facadeSource, + consumer: consumerSource + "configure(manager);", + count: 0, + }, + { + name: "custom store facade", + provider: facadeSource.replace( + "new WebStorageStateStore({store:window.localStorage})", + "{get:()=>Promise.resolve(null)}", + ), + consumer: consumerSource, + count: 0, + }, + { + name: "mutable facade", + provider: facadeSource.replace("export const manager", "export let manager"), + consumer: consumerSource, + count: 0, + }, + { + name: "type-only facade import", + provider: facadeSource, + consumer: consumerSource.replace("import {manager}", "import type {manager}"), + count: 0, + }, + ]) { + it(testCase.name, () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "facade-")); + const filename = path.join(directory, "AccountPanel.tsx"); + fs.writeFileSync(path.join(directory, "session.ts"), testCase.provider); + resetLookups(); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, testCase.consumer, { + filename, + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(testCase.count); + }); + } + it("tracks provider content, alias configuration and negative SDK remaps", () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "probes-")); + const filename = path.join(directory, "AccountPanel.tsx"); + const provider = path.join(directory, "session.ts"); + const config = path.join(directory, "tsconfig.json"); + fs.writeFileSync(provider, facadeSource); + fs.writeFileSync( + config, + JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { "@session": ["./session"], "oidc-client": ["./sdk"] }, + }, + }), + ); + const source = consumerSource.replace('"./session"', '"@session"'); + resetLookups(); + const trace = collectCrossFileDependencyProbes({ + absoluteFilePath: filename, + sourceText: source, + ruleIds: ["no-promise-then-side-effect-in-effect-without-catch"], + }); + expect(trace?.contentPaths).toContain(provider); + expect(trace?.contentPaths).toContain(config); + expect(trace?.existencePaths).toContain(path.join(directory, "sdk.ts")); + const before = runRule(noPromiseThenSideEffectInEffectWithoutCatch, source, { filename }); + expect(before.parseErrors).toEqual([]); + expect(before.diagnostics).toHaveLength(1); + fs.writeFileSync(path.join(directory, "sdk.ts"), "export class UserManager {};"); + resetLookups(); + const after = runRule(noPromiseThenSideEffectInEffectWithoutCatch, source, { filename }); + expect(after.parseErrors).toEqual([]); + expect(after.diagnostics).toHaveLength(0); + }); + it("declines a reexport rather than traversing a second facade", () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "barrel-")); + const filename = path.join(directory, "AccountPanel.tsx"); + fs.writeFileSync(path.join(directory, "session.ts"), 'export {manager} from "./actual";'); + fs.writeFileSync(path.join(directory, "actual.ts"), facadeSource); + resetLookups(); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, consumerSource, { + filename, + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toEqual([]); + }); +}); + +const strictSdkSource = `import {useEffect,useState} from "react"; +import {UserManager,WebStorageStateStore} from "oidc-client"; +const settings={userStore:new WebStorageStateStore({store:window.localStorage})}; +const manager=new UserManager(settings); +export const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser)},[]);return {user?.name}}; +`; + +describe("OIDC SDK and native alias boundaries", () => { + for (const testCase of [ + { + name: "SDK User static parser replacement", + extra: 'import {User} from "oidc-client"; User.fromStorageString=()=>({});', + count: 0, + }, + { + name: "SDK secondary constructor prototype destructuring", + extra: + 'import {UserManager as OtherManager} from "oidc-client"; const {prototype:methods}=OtherManager; methods.getUser=()=>Promise.resolve(null);', + count: 0, + }, + { + name: "SDK secondary constructor escape", + extra: 'import {UserManager as OtherManager} from "oidc-client"; configure(OtherManager);', + count: 0, + }, + { + name: "SDK namespace exposure", + extra: 'import * as sdk from "oidc-client"; sdk.User.fromStorageString=()=>({});', + count: 0, + }, + { + name: "SDK default exposure", + extra: 'import sdk from "oidc-client"; configure(sdk);', + count: 0, + }, + { + name: "SDK extra runtime User read", + extra: 'import {User} from "oidc-client"; const UserType=User;', + count: 0, + }, + { + name: "SDK erased type-only uses preserved", + extra: + 'import {User,Profile,UserManagerSettings} from "oidc-client"; interface Account {user:User;profile:Profile;settings:UserManagerSettings}', + count: 1, + }, + { + name: "SDK explicit type-only use preserved", + extra: 'import type {User} from "oidc-client"; interface Account {user:User}', + count: 1, + }, + { + name: "SDK type-only runtime misuse", + extra: 'import type {User} from "oidc-client"; User.fromStorageString=()=>({});', + count: 0, + }, + { + name: "window JSON extraction", + extra: "const parser=window.JSON; parser.parse=()=>({});", + count: 0, + }, + { + name: "recursive window alias", + extra: "const root=window.window; root.JSON.parse=()=>({});", + count: 0, + }, + { + name: "recursive window destructuring", + extra: "const {JSON:parser}=window.window; parser.parse=()=>({});", + count: 0, + }, + { name: "window decoder extraction", extra: "const decode=window.atob;", count: 0 }, + { + name: "process non-environment transport", + extra: "const root=process.mainModule;", + count: 0, + }, + { + name: "window Promise extraction", + extra: + "const NativePromise=window.Promise; NativePromise.resolve=()=>({then:()=>NativePromise.all([])});", + count: 0, + }, + { name: "window JSON direct use", extra: 'window.JSON.parse("{}");', count: 0 }, + { name: "SDK runtime reexport", extra: 'export {User} from "oidc-client";', count: 0 }, + { + name: "native original read roles preserved", + extra: + "const parse=(text)=>JSON.parse(window.atob(text)); const setting=process.env.EXAMPLE;", + count: 1, + }, + ]) { + it(testCase.name, () => { + const source = strictSdkSource + testCase.extra; + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, source); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(testCase.count); + }); + } +}); + +describe("OIDC resolved consumer import identities", () => { + for (const testCase of [ + { + name: "split same-source alias", + imports: 'import {manager as alternate} from "./session";', + mutation: "alternate.getUser=()=>Promise.resolve(null);", + count: 0, + }, + { + name: "split explicit-extension alias", + imports: 'import {manager as alternate} from "./session.ts";', + mutation: "alternate.getUser=()=>Promise.resolve(null);", + count: 0, + }, + { + name: "tsconfig alias", + imports: 'import {manager as alternate} from "@session";', + mutation: "alternate.getUser=()=>Promise.resolve(null);", + count: 0, + }, + { + name: "renamed barrel alias", + imports: 'import {renamed as alternate} from "./barrel";', + mutation: "alternate.getUser=()=>Promise.resolve(null);", + count: 0, + }, + { + name: "namespace immediate facade", + imports: 'import * as facade from "./session";', + mutation: "facade.manager.getUser=()=>Promise.resolve(null);", + count: 0, + }, + { + name: "namespace renamed barrel", + imports: 'import * as facade from "./barrel";', + mutation: "facade.renamed.getUser=()=>Promise.resolve(null);", + count: 0, + }, + { + name: "default barrel alias", + imports: 'import alternate from "./default-facade";', + mutation: "alternate.getUser=()=>Promise.resolve(null);", + count: 0, + }, + { + name: "consumer SDK parser exposure", + imports: 'import {User} from "oidc-client";', + mutation: "User.fromStorageString=()=>({});", + count: 0, + }, + { + name: "other facade values remain supported", + imports: 'import {settings as displaySettings,enter} from "./session";', + mutation: "const authority=displaySettings.authority; const begin=()=>enter();", + count: 1, + }, + { + name: "erased facade import remains supported", + imports: 'import type {manager as alternate} from "./session";', + mutation: "type Manager=typeof alternate;", + count: 1, + }, + ]) { + it(testCase.name, () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "consumer-identities-")); + const filename = path.join(directory, "AccountPanel.tsx"); + const provider = + facadeSource.replace("const settings", "export const settings") + + "export const enter=()=>manager.signinRedirect();"; + fs.writeFileSync(path.join(directory, "session.ts"), provider); + fs.writeFileSync( + path.join(directory, "barrel.ts"), + 'export {manager as renamed} from "./session";', + ); + fs.writeFileSync( + path.join(directory, "default-facade.ts"), + 'export {manager as default} from "./session";', + ); + fs.writeFileSync( + path.join(directory, "tsconfig.json"), + JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@session": ["./session"] } } }), + ); + const source = consumerSource + testCase.imports + testCase.mutation; + resetLookups(); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, source, { filename }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(testCase.count); + }); + } + it("collects a secondary alias barrel and its configuration before declining", () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "alias-probes-")); + const filename = path.join(directory, "AccountPanel.tsx"); + const provider = path.join(directory, "session.ts"); + const barrel = path.join(directory, "barrel.ts"); + const config = path.join(directory, "tsconfig.json"); + fs.writeFileSync(provider, facadeSource); + fs.writeFileSync(barrel, 'export {manager as renamed} from "./session";'); + fs.writeFileSync( + config, + JSON.stringify({ compilerOptions: { baseUrl: ".", paths: { "@barrel": ["./barrel"] } } }), + ); + const source = + consumerSource + 'import {renamed} from "@barrel"; const readAlias=()=>renamed.getUser();'; + resetLookups(); + const trace = collectCrossFileDependencyProbes({ + absoluteFilePath: filename, + sourceText: source, + ruleIds: ["no-promise-then-side-effect-in-effect-without-catch"], + }); + expect(trace?.contentPaths).toContain(provider); + expect(trace?.contentPaths).toContain(barrel); + expect(trace?.contentPaths).toContain(config); + }); +}); + +describe("OIDC constructing-module runtime import boundary", () => { + for (const testCase of [ + { + name: "provider direct self import", + path: "./session", + body: 'export {manager as alternate} from "./session";', + count: 0, + }, + { + name: "provider renamed barrel import", + path: "./bridge", + body: 'export {manager as alternate} from "./session";', + count: 0, + }, + ]) { + it(testCase.name, () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "provider-import-")); + const filename = path.join(directory, "AccountPanel.tsx"); + const importedName = testCase.path === "./session" ? "manager" : "alternate"; + const provider = + facadeSource + + `import {${importedName} as secondary} from "${testCase.path}"; secondary.getUser=()=>Promise.resolve(null);`; + fs.writeFileSync(path.join(directory, "session.ts"), provider); + fs.writeFileSync(path.join(directory, "bridge.ts"), testCase.body); + resetLookups(); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, consumerSource, { + filename, + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(testCase.count); + }); + } + + it("declines a local construction importing its own exported instance", () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "local-self-import-")); + const filename = path.join(directory, "AccountPanel.tsx"); + const source = + strictSdkSource.replace("const manager=", "export const manager=") + + 'import {manager as alternate} from "./AccountPanel"; alternate.getUser=()=>Promise.resolve(null);'; + fs.writeFileSync(filename, source); + resetLookups(); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, source, { filename }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toEqual([]); + }); + + it("preserves direct local construction with genuine React and SDK imports", () => { + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, strictSdkSource); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + + it("preserves an unrelated erased UserInfo import in a TypeScript provider", () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "erased-provider-import-")); + const filename = path.join(directory, "AccountPanel.tsx"); + const provider = + facadeSource + 'import {UserInfo} from "./types"; interface Account {user:UserInfo}'; + fs.writeFileSync(path.join(directory, "session.ts"), provider); + fs.writeFileSync(path.join(directory, "types.ts"), "export interface UserInfo {name:string}"); + resetLookups(); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, consumerSource, { + filename, + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + + it("declines an opaque runtime provider import and a side-effect import", () => { + for (const imported of [ + 'import {patch} from "./patch"; patch();', + 'import {patch} from "./patch";', + 'import "./patch";', + ]) { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "opaque-provider-import-")); + const filename = path.join(directory, "AccountPanel.tsx"); + fs.writeFileSync(path.join(directory, "session.ts"), facadeSource + imported); + fs.writeFileSync(path.join(directory, "patch.ts"), "export const patch=()=>undefined;"); + resetLookups(); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, consumerSource, { + filename, + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toEqual([]); + } + }); +}); + +describe("OIDC erased provider import identity", () => { + for (const testCase of [ + { + name: "module interface heritage", + declaration: "interface Account extends UserInfo {}", + diagnostics: 1, + }, + { + name: "module interface field", + declaration: "interface Account {user:UserInfo}", + diagnostics: 1, + }, + { + name: "exported interface heritage", + declaration: "export interface Account extends UserInfo {}", + diagnostics: 1, + }, + { + name: "exported interface field", + declaration: "export interface Account {user:UserInfo}", + diagnostics: 1, + }, + { + name: "unused value import", + declaration: "interface Account {name:string}", + diagnostics: 0, + }, + { + name: "generic interface shadows import", + declaration: "interface Account {user:UserInfo}", + diagnostics: 0, + }, + { + name: "generic method shadows import", + declaration: "interface Account {read():UserInfo}", + diagnostics: 0, + }, + { + name: "generic callable property shadows import", + declaration: "interface Account {read:()=>UserInfo}", + diagnostics: 0, + }, + { + name: "local interface shadows import", + declaration: "const describe=(UserInfo)=>{interface Account {user:UserInfo}};", + diagnostics: 0, + }, + { + name: "unrelated property key spelling", + declaration: "interface Account {UserInfo:string}", + diagnostics: 0, + }, + { + name: "computed property spelling", + declaration: "interface Account {[UserInfo]:string}", + diagnostics: 0, + }, + { + name: "qualified name is outside V1", + declaration: "interface Account {user:UserInfo.Detail}", + diagnostics: 0, + }, + ]) { + it(testCase.name, () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "erased-provider-identity-")); + const filename = path.join(directory, "AccountPanel.tsx"); + fs.writeFileSync( + path.join(directory, "session.ts"), + facadeSource + 'import {UserInfo} from "./types";' + testCase.declaration, + ); + fs.writeFileSync(path.join(directory, "types.ts"), "export interface UserInfo {name:string}"); + resetLookups(); + const result = runRule(noPromiseThenSideEffectInEffectWithoutCatch, consumerSource, { + filename, + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(testCase.diagnostics); + }); + } +}); + +describe("program provider proof growth", () => { + it("checks stable provider imports once as sibling components grow", () => { + const results = [ + PROVIDER_PROOF_SMALL_COMPONENT_COUNT, + PROVIDER_PROOF_LARGE_COMPONENT_COUNT, + ].map((size) => { + const source = + 'import {useState,useEffect} from "react"; import {UserManager,WebStorageStateStore} from "oidc-client";const settings={userStore:new WebStorageStateStore({store:window.localStorage})};const manager=new UserManager(settings);' + + Array.from( + { length: size }, + (_, index) => + `export const Panel${index}=()=>{const[user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser);},[]);return {user?.name}}`, + ).join("\n"); + let symbolVisits = 0; + const measuredRule = { + ...noPromiseThenSideEffectInEffectWithoutCatch, + create: ( + context: Parameters[0], + ) => { + const symbols = new Proxy(context.scopes.rootScope.symbols, { + get: (target, property, receiver) => { + if (typeof property === "string" && /^\d+$/.test(property)) symbolVisits += 1; + return Reflect.get(target, property, receiver); + }, + }); + const rootScope = new Proxy(context.scopes.rootScope, { + get: (target, property, receiver) => + property === "symbols" ? symbols : Reflect.get(target, property, receiver), + }); + const scopes = new Proxy(context.scopes, { + get: (target, property, receiver) => + property === "rootScope" ? rootScope : Reflect.get(target, property, receiver), + }); + return noPromiseThenSideEffectInEffectWithoutCatch.create({ ...context, scopes }); + }, + }; + const result = runRule(measuredRule, source); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(size); + return symbolVisits; + }); + expect(results[0]).toBeGreaterThan(0); + expect(results[1]).toBeLessThanOrEqual(results[0] * PROVIDER_PROOF_MAX_GROWTH_RATIO); + }); +}); + +describe("memoized OIDC provider dependencies", () => { + it("replays warm probes and refreshes the same analysis after a filesystem epoch", () => { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "memo-probes-")); + const filename = path.join(directory, "AccountPanel.tsx"); + const provider = path.join(directory, "session.ts"); + fs.writeFileSync(provider, facadeSource); + let exercised = false; + const probe: Rule = { + id: "oidc-memo-dependency-probe", + severity: "warn", + create: (context) => ({ + CallExpression: (node) => { + const cold = captureCrossFileProbes(() => isRejectableOidcUserManagerCall(node, context)); + if (!cold.value) return; + exercised = true; + const warm = captureCrossFileProbes(() => isRejectableOidcUserManagerCall(node, context)); + expect(warm.value).toBe(true); + expect([...warm.trace.contentPaths].sort()).toEqual([...cold.trace.contentPaths].sort()); + expect([...warm.trace.existencePaths].sort()).toEqual( + [...cold.trace.existencePaths].sort(), + ); + expect(cold.trace.contentPaths.has(provider)).toBe(true); + fs.writeFileSync(provider, facadeSource.replace("window.localStorage", "customStorage")); + resetFilesystemCaches(); + expect(isRejectableOidcUserManagerCall(node, context)).toBe(false); + }, + }), + }; + const result = runRule(probe, consumerSource, { filename }); + expect(result.parseErrors).toEqual([]); + expect(exercised).toBe(true); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-promise-then-side-effect-in-effect-without-catch.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-promise-then-side-effect-in-effect-without-catch.ts index d04d91dea8..551b300e84 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-promise-then-side-effect-in-effect-without-catch.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-promise-then-side-effect-in-effect-without-catch.ts @@ -1,3 +1,4 @@ +import { isRejectableOidcUserManagerCall } from "../../utils/is-rejectable-oidc-user-manager-call.js"; import { defineRule } from "../../utils/define-rule.js"; import { doNodesCoverEveryPathAfterNode } from "../../utils/do-nodes-cover-every-path-after-node.js"; import { getEffectCallback } from "../../utils/get-effect-callback.js"; @@ -354,6 +355,7 @@ const isProvablyRejectableExpression = ( return isProvablyRejectableExpression(stripped.argument, remainingDepth, context); } if (!isNodeOfType(stripped, "CallExpression")) return false; + if (isRejectableOidcUserManagerCall(stripped, context)) return true; const callee = stripParenExpression(stripped.callee); if (isNodeOfType(callee, "Identifier")) { if (callee.name === "fetch" && context.scopes.isGlobalReference(callee)) { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-rejectable-oidc-user-manager-call.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-rejectable-oidc-user-manager-call.ts new file mode 100644 index 0000000000..e6ed168846 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/is-rejectable-oidc-user-manager-call.ts @@ -0,0 +1,631 @@ +import { captureCrossFileProbes, replayCrossFileProbes } from "./cross-file-probe-recorder.js"; +import type { CapturedCrossFileProbes } from "./cross-file-probe-recorder.js"; +import { getManifestCacheGeneration } from "./read-nearest-package-manifest.js"; +import { findVisibleSymbol } from "./find-visible-symbol.js"; +import { getImportDeclarationForSymbol } from "./get-import-declaration-for-symbol.js"; +import { + OIDC_CLIENT_MODULE_SOURCE, + OIDC_GET_USER_ARGUMENT_COUNT, + OIDC_CONSTRUCTOR_ARGUMENT_COUNT, + OIDC_WINDOW_DECODER_ARGUMENT_COUNT, + OIDC_CONSTRUCTOR_ARGUMENT_INDEX, + OIDC_STORE_OPTION_PROPERTY_COUNT, + OIDC_INSTANCE_METHOD_NAMES, + OIDC_UNSUPPORTED_CAPABILITY_NAMES, + OIDC_UNSUPPORTED_MEMBER_NAMES, + OIDC_UNSUPPORTED_RUNTIME_TYPES, +} from "../constants/react.js"; +import { analyzeScopes } from "../semantic/scope-analysis.js"; +import type { ScopeAnalysis, SymbolDescriptor } from "../semantic/scope-analysis.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import type { RuleContext } from "./rule-context.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 { getStaticPropertyKeyName } from "./get-static-property-key-name.js"; +import { getStaticPropertyName } from "./get-static-property-name.js"; +import { getValueImportDeclaration } from "./get-value-import-declaration.js"; +import { hasCompleteSymbolReferences } from "./has-complete-symbol-references.js"; +import { hasPossibleStaticPropertyMutationOrEscape } from "./has-static-property-write-before.js"; +import { isWithinAssignmentTarget } from "./is-within-assignment-target.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { isNonReferenceIdentifier } from "./is-non-reference-identifier.js"; +import { isTypeScriptTypePosition } from "./is-typescript-type-position.js"; +import { resolveCrossFileValueExportWithFilePath } from "./resolve-cross-file-function-export.js"; +import { resolveImportedApiReference } from "./resolve-imported-api-reference.js"; +import { resolveModulePath } from "./resolve-module-path.js"; +import { stripParenExpression } from "./strip-paren-expression.js"; +import { walkAst } from "./walk-ast.js"; + +interface OidcProgramFacts { + readonly readonlyBindings: Map; + readonly instanceCalls: Map; + readonly managerOrigins: Map>>; + managerOriginGeneration: number; + visibleCapabilities?: boolean; +} + +const programFactsByScopes = new WeakMap>(); +const providerScopesByProgram = new WeakMap(); + +const getProgramFacts = (program: EsTreeNode, scopes: ScopeAnalysis): OidcProgramFacts => { + let factsByProgram = programFactsByScopes.get(scopes); + if (!factsByProgram) { + factsByProgram = new WeakMap(); + programFactsByScopes.set(scopes, factsByProgram); + } + let facts = factsByProgram.get(program); + if (!facts) { + facts = { + readonlyBindings: new Map(), + instanceCalls: new Map(), + managerOrigins: new Map(), + managerOriginGeneration: getManifestCacheGeneration(), + }; + factsByProgram.set(program, facts); + } + return facts; +}; + +const isReadonlyCompleteBinding = ( + symbol: SymbolDescriptor, + program: EsTreeNode, + scopes: ScopeAnalysis, +): boolean => { + const facts = getProgramFacts(program, scopes); + const cached = facts.readonlyBindings.get(symbol); + if (cached !== undefined) return cached; + const readonly = + hasCompleteSymbolReferences(symbol, program, scopes) && + symbol.references.every( + (reference) => reference.flag === "read" && !isWithinAssignmentTarget(reference.identifier), + ); + facts.readonlyBindings.set(symbol, readonly); + return readonly; +}; + +const hasSupportedVisibleCapabilities = (program: EsTreeNode, scopes: ScopeAnalysis): boolean => { + const facts = getProgramFacts(program, scopes); + if (facts.visibleCapabilities !== undefined) return facts.visibleCapabilities; + let supported = true; + walkAst(program, (node) => { + if (!supported) return false; + if ( + OIDC_UNSUPPORTED_RUNTIME_TYPES.has(node.type) || + ("declare" in node && node.declare === true) + ) { + supported = false; + return false; + } + if (isTypeScriptTypePosition(node)) return false; + if (isNodeOfType(node, "MemberExpression")) { + const propertyName = getStaticPropertyName(node); + const numericRead = + isNodeOfType(node.property, "Literal") && + typeof node.property.value === "number" && + !isWithinAssignmentTarget(node); + if (OIDC_UNSUPPORTED_MEMBER_NAMES.has(propertyName ?? "") || (node.computed && !numericRead)) + supported = false; + } + if (!isNodeOfType(node, "Identifier") || isNonReferenceIdentifier(node)) return; + if (OIDC_UNSUPPORTED_CAPABILITY_NAMES.has(node.name)) supported = false; + if (["window", "JSON", "Promise", "process"].includes(node.name)) { + if (!scopes.isGlobalReference(node) || isWithinAssignmentTarget(node)) supported = false; + const member = findTransparentExpressionRoot(node).parent; + if ( + node.name === "window" && + isNodeOfType(member, "MemberExpression") && + !["localStorage", "sessionStorage", "atob"].includes(getStaticPropertyName(member) ?? "") + ) + supported = false; + if ( + node.name === "window" && + isNodeOfType(member, "MemberExpression") && + getStaticPropertyName(member) === "atob" + ) { + const callee = findTransparentExpressionRoot(member); + const call = callee.parent; + if ( + member.optional || + !isNodeOfType(call, "CallExpression") || + call.callee !== callee || + call.optional || + call.arguments.length !== OIDC_WINDOW_DECODER_ARGUMENT_COUNT || + call.arguments.some((argument) => isNodeOfType(argument, "SpreadElement")) + ) + supported = false; + } + if ( + node.name === "process" && + (!isNodeOfType(member, "MemberExpression") || + member.optional || + getStaticPropertyName(member) !== "env") + ) + supported = false; + if ( + node.name === "window" && + isNodeOfType(member, "MemberExpression") && + ["localStorage", "sessionStorage"].includes(getStaticPropertyName(member) ?? "") + ) { + const property = member.parent; + const options = property?.parent; + const store = options?.parent; + if ( + !isNodeOfType(property, "Property") || + property.value !== member || + !isNodeOfType(options, "ObjectExpression") || + !isNodeOfType(store, "NewExpression") || + store.arguments[OIDC_CONSTRUCTOR_ARGUMENT_INDEX] !== options || + !isNativeStorageStore(store, program, scopes) + ) + supported = false; + } + if (!isNodeOfType(member, "MemberExpression") || member.object !== node || member.computed) { + if ( + !( + node.name === "Promise" && + isNodeOfType(member, "NewExpression") && + member.callee === node + ) + ) + supported = false; + } + } + }); + facts.visibleCapabilities = supported; + return supported; +}; + +const hasInstanceCallReferences = ( + symbol: SymbolDescriptor, + program: EsTreeNode, + scopes: ScopeAnalysis, +): boolean => { + const facts = getProgramFacts(program, scopes); + const cached = facts.instanceCalls.get(symbol); + if (cached !== undefined) return cached; + const supported = + isReadonlyCompleteBinding(symbol, program, scopes) && + !hasPossibleStaticPropertyMutationOrEscape(symbol.bindingIdentifier, "getUser", scopes) && + symbol.references.every((reference) => { + if (isTypeScriptTypePosition(reference.identifier)) return true; + const receiver = findTransparentExpressionRoot(reference.identifier); + const member = receiver.parent; + if ( + !isNodeOfType(member, "MemberExpression") || + member.object !== receiver || + member.computed || + member.optional || + !OIDC_INSTANCE_METHOD_NAMES.has(getStaticPropertyName(member) ?? "") + ) + return false; + const callee = findTransparentExpressionRoot(member); + const call = callee.parent; + return ( + isNodeOfType(call, "CallExpression") && + call.callee === callee && + !call.optional && + call.arguments.length === OIDC_GET_USER_ARGUMENT_COUNT + ); + }); + facts.instanceCalls.set(symbol, supported); + return supported; +}; + +const isDirectSdkConstructor = ( + expression: EsTreeNode, + exportedName: string, + selectedConstructor: EsTreeNode, + program: EsTreeNode, + scopes: ScopeAnalysis, +): boolean => { + const identifier = stripParenExpression(expression); + if (!isNodeOfType(identifier, "Identifier")) return false; + const symbol = scopes.symbolFor(identifier); + const declaration = symbol ? getValueImportDeclaration(symbol) : null; + if ( + !symbol || + !declaration || + !isNodeOfType(symbol.declarationNode, "ImportSpecifier") || + !isReadonlyCompleteBinding(symbol, program, scopes) + ) + return false; + const imported = resolveImportedApiReference(identifier, scopes); + if (imported?.source !== OIDC_CLIENT_MODULE_SOURCE || imported.importedName !== exportedName) + return false; + return symbol.references.every((reference) => { + if (isTypeScriptTypePosition(reference.identifier)) return true; + const callee = findTransparentExpressionRoot(reference.identifier); + const construct = callee.parent; + return ( + construct === selectedConstructor && + isNodeOfType(construct, "NewExpression") && + construct.callee === callee && + construct.arguments.length === OIDC_CONSTRUCTOR_ARGUMENT_COUNT + ); + }); +}; + +const getOwnDataProperty = (expression: EsTreeNode, selectedName: string): EsTreeNode | null => { + const object = stripParenExpression(expression); + if (!isNodeOfType(object, "ObjectExpression")) return null; + const names = new Set(); + let selected: EsTreeNode | null = null; + for (const property of object.properties) { + if (!isNodeOfType(property, "Property") || property.kind !== "init" || property.method) + return null; + const name = getStaticPropertyKeyName(property); + if (!name || name === "__proto__" || names.has(name)) return null; + names.add(name); + if (name === selectedName) selected = property.value; + } + return selected; +}; + +const isNativeStorageStore = ( + expression: EsTreeNode, + program: EsTreeNode, + scopes: ScopeAnalysis, +): boolean => { + const store = stripParenExpression(expression); + if ( + !isNodeOfType(store, "NewExpression") || + store.arguments.length !== OIDC_CONSTRUCTOR_ARGUMENT_COUNT || + !isDirectSdkConstructor(store.callee, "WebStorageStateStore", store, program, scopes) + ) + return false; + const options = store.arguments[OIDC_CONSTRUCTOR_ARGUMENT_INDEX]; + if ( + !isNodeOfType(options, "ObjectExpression") || + options.properties.length !== OIDC_STORE_OPTION_PROPERTY_COUNT + ) + return false; + const storage = getOwnDataProperty(options, "store"); + if (!storage) return false; + const member = stripParenExpression(storage); + if ( + !isNodeOfType(member, "MemberExpression") || + member.computed || + member.optional || + !["localStorage", "sessionStorage"].includes(getStaticPropertyName(member) ?? "") || + !isNodeOfType(member.object, "Identifier") || + member.object.name !== "window" || + !scopes.isGlobalReference(member.object) || + hasPossibleStaticPropertyMutationOrEscape( + member.object, + getStaticPropertyName(member) ?? "", + scopes, + ) + ) + return false; + return true; +}; + +const getNativeStoreConstructor = ( + expression: EsTreeNode, + constructor: EsTreeNode, + program: EsTreeNode, + scopes: ScopeAnalysis, +): EsTreeNode | null => { + let settings = stripParenExpression(expression); + if (isNodeOfType(settings, "Identifier")) { + const symbol = scopes.symbolFor(settings); + const initializer = symbol ? getDirectConstInitializer(symbol) : null; + if ( + !symbol || + !initializer || + symbol.scope.kind !== "module" || + !isReadonlyCompleteBinding(symbol, program, scopes) || + symbol.references.some( + (reference) => + !isTypeScriptTypePosition(reference.identifier) && + findTransparentExpressionRoot(reference.identifier).parent !== constructor, + ) + ) + return null; + settings = stripParenExpression(initializer); + } + const store = getOwnDataProperty(settings, "userStore"); + return store && isNativeStorageStore(store, program, scopes) ? stripParenExpression(store) : null; +}; + +const hasOnlySelectedSdkImports = ( + program: EsTreeNode, + scopes: ScopeAnalysis, + allowedConstructors: ReadonlySet, +): boolean => { + for (const symbol of scopes.rootScope.symbols) { + const declaration = getImportDeclarationForSymbol(symbol); + if (declaration?.source.value !== OIDC_CLIENT_MODULE_SOURCE) continue; + if (!isReadonlyCompleteBinding(symbol, program, scopes)) return false; + const runtimeReferences = symbol.references.filter( + (reference) => !isTypeScriptTypePosition(reference.identifier), + ); + if (runtimeReferences.length === 0) continue; + if ( + !getValueImportDeclaration(symbol) || + !isNodeOfType(symbol.declarationNode, "ImportSpecifier") + ) + return false; + const imported = resolveImportedApiReference(symbol.bindingIdentifier, scopes); + if ( + imported?.importedName !== "UserManager" && + imported?.importedName !== "WebStorageStateStore" + ) + return false; + if ( + !runtimeReferences.every((reference) => { + const callee = findTransparentExpressionRoot(reference.identifier); + const construct = callee.parent; + return ( + isNodeOfType(construct, "NewExpression") && + construct.callee === callee && + allowedConstructors.has(construct) + ); + }) + ) + return false; + } + if (!isNodeOfType(program, "Program")) return false; + return program.body.every( + (statement) => + !( + (isNodeOfType(statement, "ExportNamedDeclaration") || + isNodeOfType(statement, "ExportAllDeclaration")) && + statement.exportKind !== "type" && + statement.source?.value === OIDC_CLIENT_MODULE_SOURCE + ), + ); +}; + +const hasDirectErasedInterfaceImportUse = ( + symbol: SymbolDescriptor, + program: EsTreeNode, + scopes: ScopeAnalysis, +): boolean => { + if (!isNodeOfType(program, "Program")) return false; + return program.body.some((statement) => { + const declaration = isNodeOfType(statement, "ExportNamedDeclaration") + ? statement.declaration + : statement; + if (!isNodeOfType(declaration, "TSInterfaceDeclaration") || declaration.typeParameters) + return false; + const isSelectedImportedType = (node: EsTreeNode): boolean => + isNodeOfType(node, "Identifier") && + node.name === symbol.name && + findVisibleSymbol(node, scopes) === symbol; + if (declaration.extends.some((heritage) => isSelectedImportedType(heritage.expression))) + return true; + return declaration.body.body.some((member) => { + if (!isNodeOfType(member, "TSPropertySignature") || member.computed) return false; + const annotation = member.typeAnnotation?.typeAnnotation; + return ( + isNodeOfType(annotation, "TSTypeReference") && isSelectedImportedType(annotation.typeName) + ); + }); + }); +}; + +const hasSupportedProviderImports = ( + program: EsTreeNode, + scopes: ScopeAnalysis, + filename: string, +): boolean => { + if (!isNodeOfType(program, "Program")) return false; + if ( + !program.body.every((statement) => { + if (isNodeOfType(statement, "ImportDeclaration")) return statement.specifiers.length > 0; + return !( + (isNodeOfType(statement, "ExportNamedDeclaration") || + isNodeOfType(statement, "ExportAllDeclaration")) && + statement.source && + statement.exportKind !== "type" + ); + }) + ) + return false; + for (const symbol of scopes.rootScope.symbols) { + const declaration = getImportDeclarationForSymbol(symbol); + if (!declaration) continue; + if (!isReadonlyCompleteBinding(symbol, program, scopes)) return false; + const valueImport = getValueImportDeclaration(symbol); + const hasOnlyErasedImportUses = + !symbol.references.some((reference) => !isTypeScriptTypePosition(reference.identifier)) && + (!valueImport || hasDirectErasedInterfaceImportUse(symbol, program, scopes)); + if (hasOnlyErasedImportUses) continue; + if (!valueImport) return false; + if (declaration.source.value === OIDC_CLIENT_MODULE_SOURCE) continue; + if (declaration.source.value !== "react" || resolveModulePath(filename, "react") !== null) + return false; + } + return true; +}; + +const hasUniqueImportedManagerValue = ( + selectedImport: SymbolDescriptor, + managerConstructor: EsTreeNode, + providerFilename: string, + consumerFilename: string, + program: EsTreeNode, + scopes: ScopeAnalysis, +): boolean => { + for (const symbol of scopes.rootScope.symbols) { + const declaration = getImportDeclarationForSymbol(symbol); + if (!declaration) continue; + if (!isReadonlyCompleteBinding(symbol, program, scopes)) return false; + if (!symbol.references.some((reference) => !isTypeScriptTypePosition(reference.identifier))) + continue; + if (!getValueImportDeclaration(symbol)) return false; + const source = declaration.source.value; + if (typeof source !== "string") return false; + const directPath = resolveModulePath(consumerFilename, source); + if (!isNodeOfType(symbol.declarationNode, "ImportSpecifier")) { + if (directPath !== null) return false; + continue; + } + const imported = resolveImportedApiReference(symbol.bindingIdentifier, scopes); + if (!imported?.importedName) return false; + const resolved = resolveCrossFileValueExportWithFilePath( + consumerFilename, + source, + imported.importedName, + ); + if ( + symbol !== selectedImport && + resolved && + ((resolved.filePath === providerFilename && resolved.exportedNode === managerConstructor) || + isNodeOfType(resolved.exportedNode, "Identifier") || + isNodeOfType(resolved.exportedNode, "MemberExpression")) + ) + return false; + } + return true; +}; + +interface OidcManagerOriginInput { + readonly receiver: EsTreeNode; + readonly consumerProgram: EsTreeNode; + readonly consumerScopes: ScopeAnalysis; + readonly consumerFilename: string; +} + +const hasRejectableOidcManagerOrigin = ({ + receiver, + consumerProgram, + consumerScopes, + consumerFilename, +}: OidcManagerOriginInput): boolean => { + let symbol = consumerScopes.symbolFor(receiver); + if ( + !symbol || + !hasSupportedVisibleCapabilities(consumerProgram, consumerScopes) || + !hasInstanceCallReferences(symbol, consumerProgram, consumerScopes) + ) + return false; + const consumerSymbol = symbol; + let program: EsTreeNode = consumerProgram; + let scopes = consumerScopes; + let filename = consumerFilename; + if (symbol.kind === "import") { + if ( + !getValueImportDeclaration(symbol) || + !isNodeOfType(symbol.declarationNode, "ImportSpecifier") + ) + return false; + const imported = resolveImportedApiReference(receiver, scopes); + if (!imported?.importedName || imported.source === OIDC_CLIENT_MODULE_SOURCE) return false; + const directPath = resolveModulePath(filename, imported.source); + if (!directPath) return false; + const resolved = resolveCrossFileValueExportWithFilePath( + filename, + imported.source, + imported.importedName, + ); + if (!resolved || resolved.filePath !== directPath) return false; + const declarator = resolved.exportedNode.parent; + if ( + !isNodeOfType(declarator, "VariableDeclarator") || + !isNodeOfType(declarator.id, "Identifier") + ) + return false; + program = resolved.programNode; + scopes = providerScopesByProgram.get(program) ?? analyzeScopes(program); + providerScopesByProgram.set(program, scopes); + symbol = scopes.symbolFor(declarator.id); + filename = resolved.filePath; + if ( + !symbol || + !hasSupportedVisibleCapabilities(program, scopes) || + !hasInstanceCallReferences(symbol, program, scopes) + ) + return false; + } + const initializer = getDirectConstInitializer(symbol); + const construct = initializer ? stripParenExpression(initializer) : null; + if ( + symbol.scope.kind !== "module" || + !isNodeOfType(construct, "NewExpression") || + construct.arguments.length !== OIDC_CONSTRUCTOR_ARGUMENT_COUNT || + !isDirectSdkConstructor(construct.callee, "UserManager", construct, program, scopes) || + resolveModulePath(filename, OIDC_CLIENT_MODULE_SOURCE) !== null + ) + return false; + const settings = construct.arguments[OIDC_CONSTRUCTOR_ARGUMENT_INDEX]; + const storeConstructor = settings + ? getNativeStoreConstructor(settings, construct, program, scopes) + : null; + if (!storeConstructor) return false; + const allowedConstructors = new Set([construct, storeConstructor]); + if (!hasOnlySelectedSdkImports(program, scopes, allowedConstructors)) return false; + if (!hasSupportedProviderImports(program, scopes, filename)) return false; + if ( + program !== consumerProgram && + !hasOnlySelectedSdkImports(consumerProgram, consumerScopes, new Set()) + ) + return false; + return ( + consumerSymbol.kind !== "import" || + hasUniqueImportedManagerValue( + consumerSymbol, + construct, + filename, + consumerFilename, + consumerProgram, + consumerScopes, + ) + ); +}; + +export const isRejectableOidcUserManagerCall = ( + expression: EsTreeNode, + context: Pick, +): boolean => { + const call = stripParenExpression(expression); + if ( + !context.filename || + !isNodeOfType(call, "CallExpression") || + call.optional || + call.arguments.length !== OIDC_GET_USER_ARGUMENT_COUNT + ) + return false; + const member = stripParenExpression(call.callee); + if ( + !isNodeOfType(member, "MemberExpression") || + member.computed || + member.optional || + getStaticPropertyName(member) !== "getUser" + ) + return false; + const receiver = stripParenExpression(member.object); + if (!isNodeOfType(receiver, "Identifier")) return false; + const consumerProgram = findProgramRoot(call); + const symbol = context.scopes.symbolFor(receiver); + if (!consumerProgram || !symbol) return false; + const facts = getProgramFacts(consumerProgram, context.scopes); + const generation = getManifestCacheGeneration(); + if (facts.managerOriginGeneration !== generation) { + facts.managerOrigins.clear(); + facts.managerOriginGeneration = generation; + } + let origins = facts.managerOrigins.get(symbol); + if (!origins) { + origins = new Map(); + facts.managerOrigins.set(symbol, origins); + } + const cached = origins.get(context.filename); + if (cached !== undefined) { + replayCrossFileProbes(cached.trace); + return cached.value; + } + const consumerFilename = context.filename; + const supported = captureCrossFileProbes(() => + hasRejectableOidcManagerOrigin({ + receiver, + consumerProgram, + consumerScopes: context.scopes, + consumerFilename, + }), + ); + origins.set(context.filename, supported); + return supported.value; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/parse-source-file.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/parse-source-file.ts index ebd4f7f5bf..b9c5b927eb 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/parse-source-file.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/parse-source-file.ts @@ -87,8 +87,8 @@ export const parseSourceText = ({ }; // Module-level cache of parsed Programs keyed by absolute file path. -// Each entry stores the file's mtime + byte size at parse time so a -// changed file invalidates the cache automatically. Storing `null` +// Hosts clear entries at filesystem-cache epoch boundaries. Within one +// frozen epoch, mtime + byte size select an existing parse. Storing `null` // for known-broken files prevents re-parsing the same .d.ts / // generated / unparseable file on every rule invocation. // @@ -114,9 +114,8 @@ const rememberParsedFile = (absoluteFilePath: string, entry: CacheEntry): void = // / too large / .d.ts-only / fails to parse. // // Cached by (absolute path, mtime, size). Re-parsing only happens -// when one of those changes. Cache lives for the lifetime of the -// Node process, which matches the way oxlint runs a single batch -// of files per process. +// when one of those changes. Hosts also reset entries before a different +// filesystem epoch; jobs within one epoch share a frozen filesystem. export const parseSourceFile = (absoluteFilePath: string): EsTreeNode | null => { // Declaration files are constant-null regardless of the filesystem (absent, // present, any size — every branch below lands on `null`), so they are not @@ -187,8 +186,8 @@ export const parseSourceFile = (absoluteFilePath: string): EsTreeNode | null => return parsedProgram; }; -// Exposed for tests. Production callers don't need to clear the -// cache — the mtime/size key handles invalidation. -export const __clearParseSourceFileCacheForTests = (): void => { +export const resetParseSourceFileCache = (): void => { parseCache.clear(); }; + +export { resetParseSourceFileCache as __clearParseSourceFileCacheForTests }; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/reset-filesystem-caches.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/reset-filesystem-caches.ts index 38fb2d6332..58c38ffb54 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/reset-filesystem-caches.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/reset-filesystem-caches.ts @@ -1,4 +1,5 @@ import { resetStaticProjectDomIdCache } from "./get-static-project-dom-ids.js"; +import { resetParseSourceFileCache } from "./parse-source-file.js"; import { resetRealDirectoryCache } from "./is-package-within-project-root.js"; import { resetBundlerIgnoreAnnotatedFileTextCache } from "./read-bundler-ignore-annotated-file-text.js"; import { resetManifestCaches } from "./read-nearest-package-manifest.js"; @@ -7,6 +8,7 @@ import { resetModuleResolutionCaches } from "./resolve-relative-import-path.js"; import { resetTsconfigAliasCaches } from "./resolve-tsconfig-alias.js"; export const resetFilesystemCaches = (): void => { + resetParseSourceFileCache(); resetBundlerIgnoreAnnotatedFileTextCache(); resetCrossFileExportCaches(); resetManifestCaches(); diff --git a/packages/oxlint-plugin-react-doctor/src/test-utils/constants.ts b/packages/oxlint-plugin-react-doctor/src/test-utils/constants.ts new file mode 100644 index 0000000000..98ffa754b1 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/test-utils/constants.ts @@ -0,0 +1,3 @@ +export const PROVIDER_PROOF_SMALL_COMPONENT_COUNT = 32; +export const PROVIDER_PROOF_LARGE_COMPONENT_COUNT = 128; +export const PROVIDER_PROOF_MAX_GROWTH_RATIO = 6; diff --git a/packages/react-doctor/tests/run-oxlint/sidecar-lint-cache.test.ts b/packages/react-doctor/tests/run-oxlint/sidecar-lint-cache.test.ts index 0f72ec8325..56ba1cf0b9 100644 --- a/packages/react-doctor/tests/run-oxlint/sidecar-lint-cache.test.ts +++ b/packages/react-doctor/tests/run-oxlint/sidecar-lint-cache.test.ts @@ -335,3 +335,84 @@ export const Panel = ({ children }) => {children}; ).toBe(false); }); }); + +describe("OIDC native-storage facade dependency cache", () => { + it("matches cache-off after same-size provider edits and SDK alias creation/deletion", async () => { + const providerSource = `import {UserManager,WebStorageStateStore} from "oidc-client"; +const settings={userStore:new WebStorageStateStore({store:window.localStorage})}; +export const manager=new UserManager(settings); +`; + const customSource = `import {UserManager,WebStorageStateStore} from "oidc-client"; +const settings={userStore:{get:()=>Promise.resolve(null)}}; +export const manager=new UserManager(settings); +`; + const projectDir = setupReactProject(tempRoot, "oidc-storage-facade", { + files: { + "src/AccountPanel.tsx": `import {useEffect,useState} from "react"; +import {manager} from "./session"; +export const AccountPanel=()=>{const [user,setUser]=useState(null);useEffect(()=>{manager.getUser().then(setUser)},[]);return {user?.name}}; +`, + "src/session.ts": providerSource, + "tsconfig.json": JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "oidc-client": ["./sdk"] } }, + }), + }, + }); + fs.mkdirSync(path.join(projectDir, "node_modules"), { recursive: true }); + const scanAccount = (cached: boolean) => + runOxlint({ + rootDirectory: projectDir, + project: buildTestProject({ rootDirectory: projectDir, framework: "vite" }), + userConfig: { + rules: { "react-doctor/no-promise-then-side-effect-in-effect-without-catch": "warn" }, + }, + perFileLintCacheEnabled: cached, + sidecarLintCacheEnabled: cached, + }); + const assertPhase = async (expectedCount: number) => { + const incremental = await scanAccount(true); + const full = await scanAccount(false); + expect(serialize(incremental)).toBe(serialize(full)); + expect( + ruleHitsOn( + full, + "no-promise-then-side-effect-in-effect-without-catch", + "src/AccountPanel.tsx", + ), + ).toHaveLength(expectedCount); + }; + const providerPath = path.join(projectDir, "src/session.ts"); + const fixedTime = new Date("2025-01-01T00:00:00.000Z"); + fs.utimesSync(providerPath, fixedTime, fixedTime); + await assertPhase(1); + await scanAccount(true); + const originalStat = fs.statSync(providerPath); + expect(customSource.length).toBeLessThan(providerSource.length); + fs.writeFileSync(providerPath, customSource.padEnd(providerSource.length, " ")); + fs.utimesSync(providerPath, originalStat.atime, originalStat.mtime); + expect(fs.statSync(providerPath).size).toBe(originalStat.size); + expect(fs.statSync(providerPath).mtimeMs).toBe(originalStat.mtimeMs); + await assertPhase(0); + fs.writeFileSync(providerPath, providerSource); + fs.utimesSync(providerPath, originalStat.atime, originalStat.mtime); + await assertPhase(1); + const remapPath = path.join(projectDir, "sdk.ts"); + writeFile(remapPath, "export class UserManager {};\n"); + await assertPhase(0); + fs.unlinkSync(remapPath); + await assertPhase(1); + const consumerPath = path.join(projectDir, "src/AccountPanel.tsx"); + const unchangedAliasConsumer = + fs.readFileSync(consumerPath, "utf8") + + 'import {alternate} from "./alias"; const readAlias=()=>alternate.getUser();'; + fs.writeFileSync(consumerPath, unchangedAliasConsumer); + await assertPhase(1); + await scanAccount(true); + const aliasPath = path.join(projectDir, "src/alias.ts"); + writeFile(aliasPath, 'export {manager as alternate} from "./session";\n'); + await assertPhase(0); + fs.unlinkSync(aliasPath); + await assertPhase(1); + expect(fs.readFileSync(consumerPath, "utf8")).toBe(unchangedAliasConsumer); + }); +});