diff --git a/.changeset/fix-function-resolution-stack-overflow.md b/.changeset/fix-function-resolution-stack-overflow.md new file mode 100644 index 0000000000..15b1d0b9b5 --- /dev/null +++ b/.changeset/fix-function-resolution-stack-overflow.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +Prevent stack overflows while resolving deeply nested local function references. React Doctor now stops following a reference chain after a bounded number of steps instead of aborting the lint scan. diff --git a/.changeset/fix-implicit-subproject-entries.md b/.changeset/fix-implicit-subproject-entries.md new file mode 100644 index 0000000000..aa8f0f1edf --- /dev/null +++ b/.changeset/fix-implicit-subproject-entries.md @@ -0,0 +1,5 @@ +--- +"@react-doctor/core": patch +--- + +Use entry points from discovered implicit subprojects outside declared workspace globs, preventing legitimate files from being reported as unused. diff --git a/.changeset/fix-type-only-window.md b/.changeset/fix-type-only-window.md new file mode 100644 index 0000000000..bab0aa5321 --- /dev/null +++ b/.changeset/fix-type-only-window.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +Ignore browser-global names in TypeScript-only positions so interface and type property keys are not reported as unsafe module-scope runtime access. diff --git a/packages/core/src/project-analysis/collect/entries.ts b/packages/core/src/project-analysis/collect/entries.ts index b4632ef454..f151140227 100644 --- a/packages/core/src/project-analysis/collect/entries.ts +++ b/packages/core/src/project-analysis/collect/entries.ts @@ -203,10 +203,7 @@ export const resolveEntries = async (config: ProjectAnalysisConfig): Promise diff --git a/packages/core/tests/project-analysis-unused-file-completeness.test.ts b/packages/core/tests/project-analysis-unused-file-completeness.test.ts index 141acc0ba6..3d31c5f266 100644 --- a/packages/core/tests/project-analysis-unused-file-completeness.test.ts +++ b/packages/core/tests/project-analysis-unused-file-completeness.test.ts @@ -371,6 +371,28 @@ describe("unused-file graph completeness", () => { }, ); + it("uses entry points from implicit sub-projects outside workspace patterns", async () => { + const rootDirectory = createProject( + { + "index.js": "console.log('root');", + "packages/real/package.json": JSON.stringify({ name: "real", main: "index.js" }), + "packages/real/index.js": "export const value = 1;", + "sub/package.json": JSON.stringify({ name: "sub", main: "cli.js" }), + "sub/cli.js": "const { helper } = require('./helper'); console.log(helper());", + "sub/helper.js": "module.exports.helper = () => 'hi';", + }, + { + name: "root", + workspaces: ["packages/*"], + main: "index.js", + }, + ); + + const result = await analyzeProject({ rootDirectory }); + + expect(unusedFilePaths(rootDirectory, result.unusedFiles)).toEqual([]); + }); + it("isolates uncertainty to its owning workspace package", async () => { const rootDirectory = createProject( { diff --git a/packages/core/tests/spawn-batches-oom-rescue.test.ts b/packages/core/tests/spawn-batches-oom-rescue.test.ts index 0be51cdbd3..2df9bf9026 100644 --- a/packages/core/tests/spawn-batches-oom-rescue.test.ts +++ b/packages/core/tests/spawn-batches-oom-rescue.test.ts @@ -8,7 +8,7 @@ * partial result; a file that STILL aborts alone stays dropped and reported. * * The oxlint binary is stood in for by a `node -e` stub that aborts itself - * via `process.abort()` on each file's first attempt (tracked via per-file + * on each file's first attempt (tracked via per-file * marker files) and emits one diagnostic per file on later attempts. * `process.abort()` raises a real SIGABRT on POSIX; on Windows — which has * no POSIX signals, so a self-aborting child can never surface a signal to @@ -83,7 +83,8 @@ const buildAbortOnceScript = (abortStatement = "process.abort();"): string => "process.stdout.write(JSON.stringify({ diagnostics, number_of_files: files.length, number_of_rules: 1 }));", ].join("\n"); -const ALWAYS_ABORT_SCRIPT = "process.abort();"; +// HACK: Exit 134 preserves abort classification without generating repeated core dumps under CI load. +const ALWAYS_ABORT_SCRIPT = "process.exit(134);"; // "poison" files abort on their first attempt (so they enter the rescue), // then print non-JSON stdout — a non-splittable `OxlintOutputUnparseable` diff --git a/packages/fuzz/corpus/regressions/no-unguarded-browser-global-at-module-scope--type-property-key.ts b/packages/fuzz/corpus/regressions/no-unguarded-browser-global-at-module-scope--type-property-key.ts new file mode 100644 index 0000000000..b13cb6a892 --- /dev/null +++ b/packages/fuzz/corpus/regressions/no-unguarded-browser-global-at-module-scope--type-property-key.ts @@ -0,0 +1,17 @@ +// rule: no-unguarded-browser-global-at-module-scope +// weakness: type-position +// source: GitHub issue #1667 +// verdict: pass + +interface TweetSearchCoverageStrategyMetadata { + readonly window: + | { + readonly sinceTime: string; + readonly untilTime: string; + } + | undefined; +} + +export const metadata: TweetSearchCoverageStrategyMetadata = { + window: undefined, +}; diff --git a/packages/oxlint-plugin-react-doctor/README.md b/packages/oxlint-plugin-react-doctor/README.md index 1fb72099a2..5e6507646c 100644 --- a/packages/oxlint-plugin-react-doctor/README.md +++ b/packages/oxlint-plugin-react-doctor/README.md @@ -61,9 +61,54 @@ the package's root entry. ## Available rules -The full rule list lives in [`rule-registry.ts`](https://github.com/millionco/react-doctor/blob/main/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts). All rules are namespaced under `react-doctor/*`. +The full rule list lives in [`rule-registry.ts`](https://github.com/millionco/react-doctor/blob/main/packages/oxlint-plugin-react-doctor/src/plugin/rule-registry.ts). Rules exported by this package use the `react-doctor/*` namespace. -Rules in the `security-scan` bucket are project-level project-wide file scans (leaked artifact secrets, permissive Firebase/Supabase rules, committed key material, …). They register metadata here but are no-ops under plain oxlint or ESLint — the [React Doctor CLI](https://npmjs.com/package/react-doctor) executes them over a whole-tree file walk during its scan. +### Choose a runner for each diagnostic + +Standalone Oxlint runs rules that inspect one source file. The React Doctor CLI also runs whole-project and package analyzers. + +| Diagnostic source | Standalone Oxlint | Runner | +| --------------------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------- | +| `react-doctor/*` rules with per-file visitors | Yes | Configure them under `rules` as shown above | +| Project rules such as `react-doctor/circular-dependency` and `react-doctor/unused-dependency` | No | Run the React Doctor CLI | +| `deslop/*` diagnostics | No | Run the React Doctor CLI | +| `socket/*` diagnostics | No | Run the React Doctor CLI | +| `react-hooks-js/*` React Compiler diagnostics | Separate plugin | Register `eslint-plugin-react-hooks` or run the React Doctor CLI | + +The `deslop/*` names are CLI output aliases for project rules. These rules need the complete dependency graph, so their plugin entries have no per-file visitors. The `socket/*` diagnostics need package and supply-chain data. Neither prefix names an Oxlint plugin exported by this package. + +Rules in the `security-scan` bucket also require a project-wide file scan. They register metadata here but do nothing under standalone Oxlint or ESLint. The [React Doctor CLI](https://npmjs.com/package/react-doctor) executes them during its whole-tree scan. + +### Run React Compiler diagnostics in standalone Oxlint + +React Doctor loads React Compiler diagnostics from `eslint-plugin-react-hooks` when it detects the compiler. To use them in standalone Oxlint, install and register that plugin: + +```bash +npm install --save-dev eslint-plugin-react-hooks +``` + +Add both plugins to `.oxlintrc.json`. The `react-compiler` capability enables compiler-specific React Doctor cleanup rules: + +```jsonc +{ + "jsPlugins": [ + { "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" }, + { "name": "react-hooks-js", "specifier": "eslint-plugin-react-hooks" }, + ], + "settings": { + "react-doctor": { + "capabilities": ["react-compiler"], + }, + }, + "rules": { + "react-doctor/react-compiler-no-manual-memoization": "warn", + "react-hooks-js/immutability": "error", + "react-hooks-js/refs": "error", + }, +} +``` + +The `react-hooks-js` namespace is the alias React Doctor uses for the external plugin. It is not exported by `oxlint-plugin-react-doctor`. Each rule can be set to `"error"`, `"warn"`, or `"off"`: diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts index 0e925cc172..d08490fb9b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/constants/thresholds.ts @@ -15,6 +15,7 @@ export const RENDER_PROP_PROLIFERATION_THRESHOLD = 3; export const BOOLEAN_PROP_VARIANT_BRANCH_THRESHOLD = 2; export const GET_HANDLER_BINDING_RESOLUTION_DEPTH = 3; export const SYNCHRONOUS_THROW_RESOLUTION_DEPTH = 3; +export const FUNCTION_RESOLUTION_MAX_DEPTH = 15; // How many identifier→initializer hops jsx-key follows when proving a // `{...spread}` after an explicit `key` cannot carry a `key` of its own // (`const tokenProps = { ... }` chains). Bounded so a pathological diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unguarded-browser-global-at-module-scope.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unguarded-browser-global-at-module-scope.test.ts index 2129f244b1..21e167e51f 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unguarded-browser-global-at-module-scope.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unguarded-browser-global-at-module-scope.test.ts @@ -771,4 +771,75 @@ describe("no-unguarded-browser-global-at-module-scope", () => { ); expect(result.diagnostics).toHaveLength(0); }); + + it("does not flag type-only property names in interfaces", () => { + const result = runRule( + noUnguardedBrowserGlobalAtModuleScope, + `interface TweetSearchCoverageStrategyMetadata { + readonly window: { readonly sinceTime: string; readonly untilTime: string } | undefined; + }`, + prod, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag type-only property names in type aliases", () => { + const result = runRule( + noUnguardedBrowserGlobalAtModuleScope, + `type Config = { + window: number; + navigator: string; + };`, + prod, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + + it("still flags runtime window property access", () => { + const result = runRule( + noUnguardedBrowserGlobalAtModuleScope, + `interface Config { + window: number; + } + const w = window.innerWidth;`, + prod, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(1); + }); + + it("does not flag type parameters that happen to be named window", () => { + const result = runRule( + noUnguardedBrowserGlobalAtModuleScope, + `type MapState = (state: window) => window;`, + prod, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag type-only intersection/union member keys", () => { + const result = runRule( + noUnguardedBrowserGlobalAtModuleScope, + `type Config = { window: number } | { navigator: string } & { localStorage: boolean };`, + prod, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); + + it("does not flag method signature return types", () => { + const result = runRule( + noUnguardedBrowserGlobalAtModuleScope, + `interface API { + getWindow(): window; + } + type window = { width: number };`, + prod, + ); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unguarded-browser-global-at-module-scope.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unguarded-browser-global-at-module-scope.ts index 8e1f614fb9..af89dd3ece 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unguarded-browser-global-at-module-scope.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/correctness/no-unguarded-browser-global-at-module-scope.ts @@ -10,6 +10,7 @@ import { isFunctionLike } from "../../utils/is-function-like.js"; import { isNonSourceFilename } from "../../utils/is-non-source-filename.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import { isTestlikeFilename } from "../../utils/is-testlike-filename.js"; +import { isTypeScriptTypePosition } from "../../utils/is-typescript-type-position.js"; import { readBrowserGlobalAvailability } from "../../utils/read-browser-global-availability.js"; import { resolveCrossFileExport } from "../../utils/resolve-cross-file-export.js"; import { findTransparentExpressionRoot } from "../../utils/find-transparent-expression-root.js"; @@ -628,6 +629,7 @@ export const noUnguardedBrowserGlobalAtModuleScope = defineRule({ Identifier(node: EsTreeNodeOfType<"Identifier">) { if (!BROWSER_GLOBAL_NAMES.has(node.name)) return; if (!context.scopes.isGlobalReference(node)) return; + if (isTypeScriptTypePosition(node)) return; const expressionRoot = findTransparentExpressionRoot(node); if ( expressionRoot.parent && diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-tiny-text.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-tiny-text.performance.test.ts index 4ed06a5c79..5beb6ac816 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-tiny-text.performance.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/design/no-tiny-text.performance.test.ts @@ -13,7 +13,7 @@ const buildNestedJsxSource = (nestingDepth: number): string => { return `export const DeepTree = () => (${openingElements}{value}${closingElements});`; }; -const measureDuration = (nestingDepth: number): number => { +const measureFastestDuration = (nestingDepth: number): number => { const source = buildNestedJsxSource(nestingDepth); const sampleDurations = Array.from({ length: MEASUREMENT_SAMPLE_COUNT }, () => { const startedAt = process.hrtime.bigint(); @@ -22,16 +22,15 @@ const measureDuration = (nestingDepth: number): number => { expect(result.diagnostics).toEqual([]); return Number(process.hrtime.bigint() - startedAt); }); - sampleDurations.sort((firstDuration, secondDuration) => firstDuration - secondDuration); - return sampleDurations[Math.floor(sampleDurations.length / 2)] ?? Number.POSITIVE_INFINITY; + return Math.min(...sampleDurations); }; describe("no-tiny-text performance", () => { it("scales near-linearly across deeply nested JSX", () => { - measureDuration(SMALL_NESTING_DEPTH); - measureDuration(LARGE_NESTING_DEPTH); - const smallDuration = measureDuration(SMALL_NESTING_DEPTH); - const largeDuration = measureDuration(LARGE_NESTING_DEPTH); + measureFastestDuration(SMALL_NESTING_DEPTH); + measureFastestDuration(LARGE_NESTING_DEPTH); + const smallDuration = measureFastestDuration(SMALL_NESTING_DEPTH); + const largeDuration = measureFastestDuration(LARGE_NESTING_DEPTH); expect(largeDuration).toBeLessThan(smallDuration * MAXIMUM_SCALING_MULTIPLIER); }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.performance.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.performance.test.ts index 03d3e932fa..249f822b8a 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.performance.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-derived-state.performance.test.ts @@ -29,7 +29,7 @@ const buildEffectWithLocalDerivations = (localCount: number): string => { `; }; -const measureDuration = (localCount: number): number => { +const measureFastestDuration = (localCount: number): number => { const source = buildEffectWithLocalDerivations(localCount); const sampleDurations = Array.from({ length: MEASUREMENT_SAMPLE_COUNT }, () => { const startedAt = process.hrtime.bigint(); @@ -38,16 +38,15 @@ const measureDuration = (localCount: number): number => { expect(result.diagnostics).toHaveLength(1); return Number(process.hrtime.bigint() - startedAt); }); - sampleDurations.sort((firstDuration, secondDuration) => firstDuration - secondDuration); - return sampleDurations[Math.floor(sampleDurations.length / 2)] ?? Number.POSITIVE_INFINITY; + return Math.min(...sampleDurations); }; describe("no-derived-state performance", () => { it("scales near-linearly with effect-local derivations", () => { - measureDuration(SMALL_LOCAL_COUNT); - measureDuration(LARGE_LOCAL_COUNT); - const smallDuration = measureDuration(SMALL_LOCAL_COUNT); - const largeDuration = measureDuration(LARGE_LOCAL_COUNT); + measureFastestDuration(SMALL_LOCAL_COUNT); + measureFastestDuration(LARGE_LOCAL_COUNT); + const smallDuration = measureFastestDuration(SMALL_LOCAL_COUNT); + const largeDuration = measureFastestDuration(LARGE_LOCAL_COUNT); expect(largeDuration).toBeLessThan(smallDuration * MAXIMUM_SCALING_MULTIPLIER); }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts index 342546cdde..c328a49d12 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/state-and-effects/no-mutate-then-set-or-return-same-reference.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { runRule } from "../../../test-utils/run-rule.js"; import { noMutateThenSetOrReturnSameReference } from "./no-mutate-then-set-or-return-same-reference.js"; +const MEASUREMENT_SAMPLE_COUNT = 3; + describe("no-mutate-then-set-or-return-same-reference", () => { it("flags setX(state.add(index)) on a state Set", () => { const result = runRule( @@ -582,10 +584,10 @@ describe("no-mutate-then-set-or-return-same-reference", () => { runRule(noMutateThenSetOrReturnSameReference, buildSource(200)); const measureFastestDuration = (pairCount: number): number => { let fastestDuration = Number.POSITIVE_INFINITY; - for (let repetition = 0; repetition < 2; repetition += 1) { - const start = performance.now(); + for (let repetition = 0; repetition < MEASUREMENT_SAMPLE_COUNT; repetition += 1) { + const startedAt = performance.now(); const result = runRule(noMutateThenSetOrReturnSameReference, buildSource(pairCount)); - fastestDuration = Math.min(fastestDuration, performance.now() - start); + fastestDuration = Math.min(fastestDuration, performance.now() - startedAt); expect(result.diagnostics).toHaveLength(0); } return fastestDuration; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-exact-local-function.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-exact-local-function.test.ts new file mode 100644 index 0000000000..8a5b22293e --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-exact-local-function.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vite-plus/test"; +import { FUNCTION_RESOLUTION_MAX_DEPTH } from "../constants/thresholds.js"; +import { analyzeScopes, type ScopeAnalysis } from "../semantic/scope-analysis.js"; +import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { parseSourceText } from "./parse-source-file.js"; +import { resolveExactLocalFunction } from "./resolve-exact-local-function.js"; + +interface ParsedCallExpression { + callExpression: EsTreeNodeOfType<"CallExpression">; + scopes: ScopeAnalysis; +} + +const FUNCTION_RESOLUTION_STACK_STRESS_CHAIN_LENGTH = 3_375; + +const buildMemberFunctionChainSource = (chainLength: number): string => { + const statements = ["const object0 = { handler: () => {} };"]; + for (let index = 1; index <= chainLength; index++) { + statements.push(`const object${index} = { handler: object${index - 1}.handler };`); + } + statements.push(`object${chainLength}.handler();`); + return statements.join("\n"); +}; + +const parseLastCallExpression = (sourceText: string): ParsedCallExpression => { + const program = parseSourceText({ filename: "/tmp/test.ts", sourceText }); + if (!program || !isNodeOfType(program, "Program")) throw new Error("Expected source to parse"); + + const expressionStatement = program.body.at(-1); + if (!expressionStatement || !isNodeOfType(expressionStatement, "ExpressionStatement")) { + throw new Error("Expected ExpressionStatement"); + } + const callExpression = expressionStatement.expression; + if (!isNodeOfType(callExpression, "CallExpression")) { + throw new Error("Expected CallExpression"); + } + return { callExpression, scopes: analyzeScopes(program) }; +}; + +describe("resolveExactLocalFunction", () => { + it("resolves a simple function reference", () => { + const { callExpression, scopes } = parseLastCallExpression(` + const helper = () => {}; + helper(); + `); + + const resolved = resolveExactLocalFunction(callExpression.callee, scopes); + expect(resolved?.type).toBe("ArrowFunctionExpression"); + }); + + it("returns null for a pathological member chain without overflowing the stack", () => { + const { callExpression, scopes } = parseLastCallExpression( + buildMemberFunctionChainSource(FUNCTION_RESOLUTION_STACK_STRESS_CHAIN_LENGTH), + ); + + const resolved = resolveExactLocalFunction(callExpression.callee, scopes); + expect(resolved).toBe(null); + }); + + it("resolves a member chain within the depth limit", () => { + const resolvableChainLength = FUNCTION_RESOLUTION_MAX_DEPTH - 1; + const { callExpression, scopes } = parseLastCallExpression( + buildMemberFunctionChainSource(resolvableChainLength), + ); + + const resolved = resolveExactLocalFunction(callExpression.callee, scopes); + expect(resolved?.type).toBe("ArrowFunctionExpression"); + }); + + it("resolves through member expressions within depth limit", () => { + const { callExpression, scopes } = parseLastCallExpression(` + const obj = { + helper: () => {} + }; + obj.helper(); + `); + + const resolved = resolveExactLocalFunction(callExpression.callee, scopes); + expect(resolved?.type).toBe("ArrowFunctionExpression"); + }); + + it("resolves a bound function within the depth limit", () => { + const { callExpression, scopes } = parseLastCallExpression(` + const helper = () => {}; + const bound = helper.bind(null); + bound(); + `); + + const resolved = resolveExactLocalFunction(callExpression.callee, scopes); + expect(resolved?.type).toBe("ArrowFunctionExpression"); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-exact-local-function.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-exact-local-function.ts index abc3f64612..017d6a0522 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-exact-local-function.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-exact-local-function.ts @@ -1,3 +1,4 @@ +import { FUNCTION_RESOLUTION_MAX_DEPTH } from "../constants/thresholds.js"; import type { ScopeAnalysis } from "../semantic/scope-analysis.js"; import type { EsTreeNode } from "./es-tree-node.js"; import { findEnclosingFunction } from "./find-enclosing-function.js"; @@ -67,15 +68,21 @@ const resolveObjectPropertyFunction = ( propertyName: string, scopes: ScopeAnalysis, visitedSymbolIds: Set, + remainingDepth: number, ): EsTreeNode | null => { - if (!isNodeOfType(objectExpression, "ObjectExpression")) return null; + if (remainingDepth <= 0 || !isNodeOfType(objectExpression, "ObjectExpression")) return null; for (const property of objectExpression.properties.toReversed()) { if (!isNodeOfType(property, "Property")) return null; const candidatePropertyName = getResolvedStaticPropertyName(property, scopes); if (candidatePropertyName === null) return null; if (candidatePropertyName !== propertyName) continue; if (property.kind !== "init") return null; - return resolveExactLocalFunctionInternal(property.value, scopes, visitedSymbolIds); + return resolveExactLocalFunctionInternal( + property.value, + scopes, + visitedSymbolIds, + remainingDepth - 1, + ); } return null; }; @@ -85,8 +92,9 @@ const resolvePossibleObjectPropertyFunctions = ( propertyName: string, scopes: ScopeAnalysis, visitedSymbolIds: Set, + remainingDepth: number, ): EsTreeNode[] => { - if (!isNodeOfType(objectExpression, "ObjectExpression")) return []; + if (remainingDepth <= 0 || !isNodeOfType(objectExpression, "ObjectExpression")) return []; let possibleFunctions: EsTreeNode[] = []; const possibleFunctionSet = new Set(); for (const property of objectExpression.properties) { @@ -94,7 +102,12 @@ const resolvePossibleObjectPropertyFunctions = ( const candidatePropertyName = getResolvedStaticPropertyName(property, scopes); const candidateFunction = property.kind === "init" || property.kind === "get" - ? resolveExactLocalFunctionInternal(property.value, scopes, new Set(visitedSymbolIds)) + ? resolveExactLocalFunctionInternal( + property.value, + scopes, + new Set(visitedSymbolIds), + remainingDepth - 1, + ) : null; if (candidatePropertyName === propertyName) { possibleFunctions = candidateFunction ? [candidateFunction] : []; @@ -117,8 +130,12 @@ const resolveStaticClassFunction = ( propertyName: string, scopes: ScopeAnalysis, visitedSymbolIds: Set, + remainingDepth: number, ): EsTreeNode | null => { - if (!isNodeOfType(classNode, "ClassDeclaration") && !isNodeOfType(classNode, "ClassExpression")) { + if ( + remainingDepth <= 0 || + (!isNodeOfType(classNode, "ClassDeclaration") && !isNodeOfType(classNode, "ClassExpression")) + ) { return null; } for (const classElement of classNode.body.body.toReversed()) { @@ -136,7 +153,12 @@ const resolveStaticClassFunction = ( return null; } return classElement.value - ? resolveExactLocalFunctionInternal(classElement.value, scopes, visitedSymbolIds) + ? resolveExactLocalFunctionInternal( + classElement.value, + scopes, + visitedSymbolIds, + remainingDepth - 1, + ) : null; } return null; @@ -147,8 +169,12 @@ const resolvePossibleStaticClassFunctions = ( propertyName: string, scopes: ScopeAnalysis, visitedSymbolIds: Set, + remainingDepth: number, ): EsTreeNode[] => { - if (!isNodeOfType(classNode, "ClassDeclaration") && !isNodeOfType(classNode, "ClassExpression")) { + if ( + remainingDepth <= 0 || + (!isNodeOfType(classNode, "ClassDeclaration") && !isNodeOfType(classNode, "ClassExpression")) + ) { return []; } let possibleFunctions: EsTreeNode[] = []; @@ -167,7 +193,12 @@ const resolvePossibleStaticClassFunctions = ( (!isNodeOfType(classElement, "MethodDefinition") || classElement.kind === "method" || classElement.kind === "get") - ? resolveExactLocalFunctionInternal(classElement.value, scopes, new Set(visitedSymbolIds)) + ? resolveExactLocalFunctionInternal( + classElement.value, + scopes, + new Set(visitedSymbolIds), + remainingDepth - 1, + ) : null; if (candidatePropertyName === propertyName) { possibleFunctions = candidateFunction ? [candidateFunction] : []; @@ -193,9 +224,10 @@ const resolveAssignedMemberFunction = ( initialPossibleFunctions: EsTreeNode[], scopes: ScopeAnalysis, visitedSymbolIds: Set, + remainingDepth: number, ): MemberFunctionResolution => { const callBoundary = getExecutionBoundary(memberExpression); - if (!callBoundary || !isNodeOfType(receiver, "Identifier")) { + if (remainingDepth <= 0 || !callBoundary || !isNodeOfType(receiver, "Identifier")) { return { exactFunction: null, possibleFunctions: [] }; } const mutations: MemberFunctionMutation[] = []; @@ -265,6 +297,7 @@ const resolveAssignedMemberFunction = ( mutation.assignedExpression, scopes, new Set(visitedSymbolIds), + remainingDepth - 1, ) : null; if (mutation.isDefinite) { @@ -287,8 +320,9 @@ const resolveMemberFunction = ( memberExpression: EsTreeNode, scopes: ScopeAnalysis, visitedSymbolIds: Set, + remainingDepth: number, ): MemberFunctionResolution => { - if (!isNodeOfType(memberExpression, "MemberExpression")) { + if (remainingDepth <= 0 || !isNodeOfType(memberExpression, "MemberExpression")) { return { exactFunction: null, possibleFunctions: [] }; } const propertyName = getStaticPropertyName(memberExpression); @@ -300,6 +334,7 @@ const resolveMemberFunction = ( propertyName, scopes, visitedSymbolIds, + remainingDepth, ); return { exactFunction, @@ -308,6 +343,7 @@ const resolveMemberFunction = ( propertyName, scopes, visitedSymbolIds, + remainingDepth, ), }; } @@ -317,6 +353,7 @@ const resolveMemberFunction = ( propertyName, scopes, visitedSymbolIds, + remainingDepth, ); return { exactFunction, @@ -325,6 +362,7 @@ const resolveMemberFunction = ( propertyName, scopes, visitedSymbolIds, + remainingDepth, ), }; } @@ -345,12 +383,14 @@ const resolveMemberFunction = ( propertyName, scopes, visitedSymbolIds, + remainingDepth, ); initialPossibleFunctions = resolvePossibleObjectPropertyFunctions( initializer, propertyName, scopes, visitedSymbolIds, + remainingDepth, ); } else if (receiverSymbol.kind === "class" && receiverSymbol.initializer) { initialFunction = resolveStaticClassFunction( @@ -358,12 +398,14 @@ const resolveMemberFunction = ( propertyName, scopes, visitedSymbolIds, + remainingDepth, ); initialPossibleFunctions = resolvePossibleStaticClassFunctions( receiverSymbol.initializer, propertyName, scopes, visitedSymbolIds, + remainingDepth, ); } return resolveAssignedMemberFunction( @@ -374,6 +416,7 @@ const resolveMemberFunction = ( initialPossibleFunctions, scopes, visitedSymbolIds, + remainingDepth, ); }; @@ -381,12 +424,19 @@ const resolvePossibleLocalFunctionsInternal = ( expression: EsTreeNode, scopes: ScopeAnalysis, visitedSymbolIds: Set, + remainingDepth: number, ): EsTreeNode[] => { + if (remainingDepth <= 0) return []; const unwrappedExpression = stripParenExpression(expression); if (isNodeOfType(unwrappedExpression, "CallExpression")) { const callee = stripParenExpression(unwrappedExpression.callee); return isNodeOfType(callee, "MemberExpression") && getStaticPropertyName(callee) === "bind" - ? resolvePossibleLocalFunctionsInternal(callee.object, scopes, visitedSymbolIds) + ? resolvePossibleLocalFunctionsInternal( + callee.object, + scopes, + visitedSymbolIds, + remainingDepth - 1, + ) : []; } if (isNodeOfType(unwrappedExpression, "MemberExpression")) { @@ -396,14 +446,17 @@ const resolvePossibleLocalFunctionsInternal = ( unwrappedExpression.object, scopes, visitedSymbolIds, + remainingDepth - 1, ); } - return resolveMemberFunction(unwrappedExpression, scopes, visitedSymbolIds).possibleFunctions; + return resolveMemberFunction(unwrappedExpression, scopes, visitedSymbolIds, remainingDepth) + .possibleFunctions; } const exactFunction = resolveExactLocalFunctionInternal( unwrappedExpression, scopes, visitedSymbolIds, + remainingDepth, ); return exactFunction ? [exactFunction] : []; }; @@ -412,13 +465,20 @@ const resolveExactLocalFunctionInternal = ( expression: EsTreeNode, scopes: ScopeAnalysis, visitedSymbolIds: Set, + remainingDepth: number, ): EsTreeNode | null => { const unwrappedExpression = stripParenExpression(expression); if (isFunctionLike(unwrappedExpression)) return unwrappedExpression; + if (remainingDepth <= 0) return null; if (isNodeOfType(unwrappedExpression, "CallExpression")) { const callee = stripParenExpression(unwrappedExpression.callee); if (isNodeOfType(callee, "MemberExpression") && getStaticPropertyName(callee) === "bind") { - return resolveExactLocalFunctionInternal(callee.object, scopes, visitedSymbolIds); + return resolveExactLocalFunctionInternal( + callee.object, + scopes, + visitedSymbolIds, + remainingDepth - 1, + ); } return null; } @@ -429,9 +489,11 @@ const resolveExactLocalFunctionInternal = ( unwrappedExpression.object, scopes, visitedSymbolIds, + remainingDepth - 1, ); } - return resolveMemberFunction(unwrappedExpression, scopes, visitedSymbolIds).exactFunction; + return resolveMemberFunction(unwrappedExpression, scopes, visitedSymbolIds, remainingDepth) + .exactFunction; } if (!isNodeOfType(unwrappedExpression, "Identifier")) return null; const symbol = resolveConstIdentifierAlias(unwrappedExpression, scopes); @@ -442,15 +504,27 @@ const resolveExactLocalFunctionInternal = ( return !isReassigned && isFunctionLike(symbol.declarationNode) ? symbol.declarationNode : null; } if (symbol.kind !== "const" || !symbol.initializer) return null; - return resolveExactLocalFunctionInternal(symbol.initializer, scopes, visitedSymbolIds); + return resolveExactLocalFunctionInternal( + symbol.initializer, + scopes, + visitedSymbolIds, + remainingDepth - 1, + ); }; export const resolveExactLocalFunction = ( expression: EsTreeNode, scopes: ScopeAnalysis, -): EsTreeNode | null => resolveExactLocalFunctionInternal(expression, scopes, new Set()); +): EsTreeNode | null => + resolveExactLocalFunctionInternal(expression, scopes, new Set(), FUNCTION_RESOLUTION_MAX_DEPTH); export const resolvePossibleLocalFunctions = ( expression: EsTreeNode, scopes: ScopeAnalysis, -): EsTreeNode[] => resolvePossibleLocalFunctionsInternal(expression, scopes, new Set()); +): EsTreeNode[] => + resolvePossibleLocalFunctionsInternal( + expression, + scopes, + new Set(), + FUNCTION_RESOLUTION_MAX_DEPTH, + ); diff --git a/packages/react-doctor/tests/regressions/scan-resilience.test.ts b/packages/react-doctor/tests/regressions/scan-resilience.test.ts index 63a0eda4fe..43cf790143 100644 --- a/packages/react-doctor/tests/regressions/scan-resilience.test.ts +++ b/packages/react-doctor/tests/regressions/scan-resilience.test.ts @@ -31,6 +31,7 @@ import { batchIncludePaths, createOxlintConfig, OXLINT_MAX_FILES_PER_BATCH, + runOxlint, SPAWN_ARGS_MAX_LENGTH_CHARS, } from "@react-doctor/core"; import { @@ -909,3 +910,75 @@ describe("issue #921: non-string `projects` config entry crashes selectProjects" expect(loaded?.config.projects).toBeUndefined(); }); }); + +describe("issue #1657: stack overflow with zustand + Next.js + path aliases", () => { + it("completes without crashing when scanning zustand store with path aliases", async () => { + const projectDir = setupReactProject(tempRoot, "issue-1657-zustand-nextjs", { + packageJsonExtras: { + dependencies: { + next: "15.0.0", + react: "19.2.4", + "react-dom": "19.2.4", + zustand: "5.0.14", + }, + }, + files: { + "src/lib/preferences/theme.ts": `export type Theme = 'light' | 'dark' | 'system'; +export const DEFAULT_THEME: Theme = 'system';`, + "src/lib/preferences/theme-utils.ts": `import { type Theme, DEFAULT_THEME } from './theme'; + +export const resolveTheme = (theme: Theme): 'light' | 'dark' => { + if (theme === 'system') { + return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; + } + return theme; +}; + +export const getInitialTheme = (): Theme => DEFAULT_THEME;`, + "src/lib/preferences/preference-runtime.ts": `import { resolveTheme, getInitialTheme } from './theme-utils'; + +export const runtime = { + resolve: resolveTheme, + getInitial: getInitialTheme, +};`, + "src/stores/preferences/preferences-store.ts": `import { createStore } from 'zustand/vanilla'; +import { runtime } from '@/lib/preferences/preference-runtime'; +import type { Theme } from '@/lib/preferences/theme'; + +interface PreferencesState { + theme: Theme; + setTheme: (theme: Theme) => void; + getResolvedTheme: () => 'light' | 'dark'; +} + +export const preferencesStore = createStore()((set, get) => ({ + theme: runtime.getInitial(), + setTheme: (theme) => set({ theme }), + getResolvedTheme: () => runtime.resolve(get().theme), +}));`, + }, + }); + writeJson(path.join(projectDir, "tsconfig.json"), { + compilerOptions: { + baseUrl: ".", + jsx: "preserve", + module: "esnext", + paths: { "@/*": ["./src/*"] }, + target: "es2022", + }, + }); + + const project = discoverProject(projectDir); + expect(project.framework).toBe("nextjs"); + + await expect( + runOxlint({ + rootDirectory: projectDir, + project, + includePaths: [path.join(projectDir, "src/stores/preferences/preferences-store.ts")], + }), + ).resolves.toEqual(expect.any(Array)); + }); +});