From 905607f7fc2240304cbad5f41d3ad496eab06b17 Mon Sep 17 00:00:00 2001 From: Skosh Date: Sat, 22 Aug 2026 04:55:21 +0300 Subject: [PATCH 1/5] fix: prevent stack overflow in function resolution with depth limiting (#1658) * fix: prevent stack overflow in function resolution with depth limiting Add depth limiting to resolveExactLocalFunction to prevent infinite recursion when resolving function references through deeply nested expressions, TypeScript path aliases, or complex import chains. The function now enforces a configurable depth limit (FUNCTION_RESOLUTION_MAX_DEPTH = 15) and returns null when exhausted rather than continuing to recurse. - Add FUNCTION_RESOLUTION_MAX_DEPTH constant (15 levels) - Thread remainingDepth parameter through all resolution helpers - Add unit tests for depth limiting - Add regression test for zustand + Next.js + path aliases case Closes #1657 Co-authored-by: Skosh * test: exercise function resolution stack guard * refactor: share function chain fixture builder * test: tighten function resolution regressions --------- Co-authored-by: Cursor Agent Co-authored-by: Skosh Co-authored-by: Aiden Bai --- .../fix-function-resolution-stack-overflow.md | 5 + .../src/plugin/constants/thresholds.ts | 1 + .../resolve-exact-local-function.test.ts | 92 +++++++++++++++ .../utils/resolve-exact-local-function.ts | 108 +++++++++++++++--- .../tests/regressions/scan-resilience.test.ts | 73 ++++++++++++ 5 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 .changeset/fix-function-resolution-stack-overflow.md create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/resolve-exact-local-function.test.ts 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/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/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)); + }); +}); From 77aec24f42fa8a2c55504550df929ea3985b7748 Mon Sep 17 00:00:00 2001 From: Skosh Date: Sat, 22 Aug 2026 04:55:41 +0300 Subject: [PATCH 2/5] fix: extract entry points from implicit sub-projects outside workspace patterns (#1666) * fix: extract entry points from implicit sub-projects outside workspace patterns When the root package.json declares workspaces, sub-projects outside the declared workspace globs were discovered but their entry points (main, bin, exports) were skipped. This caused false positives in unused-file detection. The fix removes the isDeclaredWorkspace predicate from entry extraction - any package with a valid package.json should have its entry points extracted regardless of workspace pattern matching. Fixes #1665 Co-authored-by: Skosh * chore: add changeset for issue #1665 fix Co-authored-by: Skosh * test: consolidate implicit workspace entry regression * docs(changeset): clarify implicit entry fix --------- Co-authored-by: Cursor Agent Co-authored-by: Skosh Co-authored-by: Aiden Bai --- .changeset/fix-implicit-subproject-entries.md | 5 +++++ .../src/project-analysis/collect/entries.ts | 5 +---- ...-analysis-unused-file-completeness.test.ts | 22 +++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-implicit-subproject-entries.md 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/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( { From 5bc88ae6a0cd7518ffa8c6348f9176868d00ea77 Mon Sep 17 00:00:00 2001 From: Skosh Date: Sat, 22 Aug 2026 04:56:00 +0300 Subject: [PATCH 3/5] fix: ignore TypeScript type-only positions in no-unguarded-browser-global-at-module-scope (#1668) * fix: ignore TypeScript type-only positions in no-unguarded-browser-global-at-module-scope The rule was incorrectly flagging browser-global names (window, navigator, etc.) when they appeared as property keys in TypeScript interfaces and type aliases. These are type-only positions that TypeScript erases during compilation, so no runtime reference error occurs. Add isTypeScriptTypePosition check to skip identifiers in TS type contexts before reporting. Add regression tests covering interface properties, type alias properties, and the preserved behavior for actual runtime references. Fixes #1667 Co-authored-by: Skosh * chore: add changeset for #1667 fix Co-authored-by: Skosh * test: add edge case tests for TypeScript type positions Co-authored-by: Skosh * test: preserve type-only browser global regression * docs(changeset): clarify type-only global fix --------- Co-authored-by: Cursor Agent Co-authored-by: Skosh Co-authored-by: Aiden Bai --- .changeset/fix-type-only-window.md | 5 ++ ...obal-at-module-scope--type-property-key.ts | 17 +++++ ...ded-browser-global-at-module-scope.test.ts | 71 +++++++++++++++++++ ...nguarded-browser-global-at-module-scope.ts | 2 + 4 files changed, 95 insertions(+) create mode 100644 .changeset/fix-type-only-window.md create mode 100644 packages/fuzz/corpus/regressions/no-unguarded-browser-global-at-module-scope--type-property-key.ts 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/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/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 && From 43e90c4244b68462f218863d014940da67ebc94b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 21 Aug 2026 18:56:19 -0700 Subject: [PATCH 4/5] docs: explain standalone Oxlint rule coverage (#1669) --- packages/oxlint-plugin-react-doctor/README.md | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) 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"`: From e183c3519010599d929ed14d99a18bf1f8f8a44c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 22 Aug 2026 05:03:20 +0000 Subject: [PATCH 5/5] test: stabilize CI resource-sensitive guards --- .../core/tests/spawn-batches-oom-rescue.test.ts | 5 +++-- .../rules/design/no-tiny-text.performance.test.ts | 13 ++++++------- .../no-derived-state.performance.test.ts | 13 ++++++------- ...mutate-then-set-or-return-same-reference.test.ts | 8 +++++--- 4 files changed, 20 insertions(+), 19 deletions(-) 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/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;