From 072d37e8e4f82454d2e187114d0194f26efc1bf0 Mon Sep 17 00:00:00 2001 From: Ray Arayilakath Date: Fri, 3 Jul 2026 17:59:23 -0500 Subject: [PATCH 01/33] perf(plugin): memoize closureCaptures per scope analysis and function node (#1039) Nested callbacks now compute once and every enclosing function and calling rule reuses the shared result; the defensive per-reference containment re-filter is dropped (the walk already guarantees it). Co-authored-by: Claude Fable 5 --- .changeset/perf-closure-captures-memo.md | 5 + .../plugin/semantic/closure-captures.test.ts | 151 ++++++++++++++++++ .../src/plugin/semantic/closure-captures.ts | 56 +++++-- 3 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 .changeset/perf-closure-captures-memo.md create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.test.ts diff --git a/.changeset/perf-closure-captures-memo.md b/.changeset/perf-closure-captures-memo.md new file mode 100644 index 0000000000..e77766ae83 --- /dev/null +++ b/.changeset/perf-closure-captures-memo.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +perf: memoize `closureCaptures` per (ScopeAnalysis, function node) so nested callbacks compute once and every calling rule reuses the result, and drop the redundant per-reference containment re-filter diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.test.ts new file mode 100644 index 0000000000..9ce037de84 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "@voidzero-dev/vite-plus-test"; +import { closureCaptures } from "./closure-captures.js"; +import { analyzeScopes } from "./scope-analysis.js"; +import { attachParentReferences } from "../../test-utils/attach-parent-references.js"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import type { EsTreeNode } from "../utils/es-tree-node.js"; +import { isFunctionLike } from "../utils/is-function-like.js"; + +const analyze = (code: string) => { + const parsed = parseFixture(code); + attachParentReferences(parsed.program); + return { scopes: analyzeScopes(parsed.program), program: parsed.program }; +}; + +const findFunctionNode = (root: EsTreeNode, name: string): EsTreeNode | null => { + let result: EsTreeNode | null = null; + const visit = (node: EsTreeNode): void => { + if (result) return; + if ( + node.type === "FunctionDeclaration" && + (node as { id?: { name?: string } }).id?.name === name + ) { + result = node; + return; + } + if (node.type === "VariableDeclarator") { + const declarator = node as { id?: { name?: string }; init?: EsTreeNode }; + if (declarator.id?.name === name && declarator.init && isFunctionLike(declarator.init)) { + result = declarator.init; + return; + } + } + const record = node as unknown as Record; + for (const key of Object.keys(record)) { + if (key === "parent") continue; + const child = record[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === "object" && "type" in item) { + visit(item as EsTreeNode); + if (result) return; + } + } + } else if (child && typeof child === "object" && "type" in (child as object)) { + visit(child as EsTreeNode); + } + } + }; + visit(root); + return result; +}; + +const capturedNames = (captures: ReadonlyArray<{ resolvedSymbol: { name: string } | null }>) => + captures.map((capture) => capture.resolvedSymbol?.name).sort(); + +describe("closureCaptures", () => { + it("collects references whose binding lives outside the function", () => { + const { scopes, program } = analyze(` + const useGreeting = () => { + const greeting = "hi"; + const speak = () => greeting; + return speak; + }; + `); + const speak = findFunctionNode(program, "speak")!; + expect(capturedNames(closureCaptures(speak, scopes))).toEqual(["greeting"]); + }); + + it("excludes parameters and internal locals", () => { + const { scopes, program } = analyze(` + const shout = (subject) => { + const punctuation = "!"; + return subject + punctuation; + }; + `); + const shout = findFunctionNode(program, "shout")!; + expect(closureCaptures(shout, scopes)).toEqual([]); + }); + + it("excludes the function's own recursive self-reference", () => { + const { scopes, program } = analyze(` + function countdown(steps) { + if (steps === 0) return; + countdown(steps - 1); + } + `); + const countdown = findFunctionNode(program, "countdown")!; + expect(closureCaptures(countdown, scopes)).toEqual([]); + }); + + it("bubbles nested-function captures up to every enclosing function", () => { + const { scopes, program } = analyze(` + const useOuter = () => { + const outerValue = 1; + const middle = () => { + const middleValue = 2; + const inner = () => outerValue + middleValue; + return inner; + }; + return middle; + }; + `); + const middle = findFunctionNode(program, "middle")!; + const inner = findFunctionNode(program, "inner")!; + expect(capturedNames(closureCaptures(inner, scopes))).toEqual(["middleValue", "outerValue"]); + expect(capturedNames(closureCaptures(middle, scopes))).toEqual(["outerValue"]); + const useOuter = findFunctionNode(program, "useOuter")!; + expect(closureCaptures(useOuter, scopes)).toEqual([]); + }); + + it("excludes globals (unresolved references)", () => { + const { scopes, program } = analyze(` + const report = () => { + console.log(window.location.href); + }; + `); + const report = findFunctionNode(program, "report")!; + expect(closureCaptures(report, scopes)).toEqual([]); + }); + + it("returns the memoized result on repeat calls with the same analysis", () => { + const { scopes, program } = analyze(` + const useCounter = () => { + const step = 1; + const increment = (count) => count + step; + return increment; + }; + `); + const increment = findFunctionNode(program, "increment")!; + const firstResult = closureCaptures(increment, scopes); + const secondResult = closureCaptures(increment, scopes); + expect(secondResult).toBe(firstResult); + expect(capturedNames(secondResult)).toEqual(["step"]); + }); + + it("computes fresh results for a different ScopeAnalysis over the same AST", () => { + const { scopes, program } = analyze(` + const useLabel = () => { + const label = "x"; + const describeLabel = () => label; + return describeLabel; + }; + `); + const describeLabel = findFunctionNode(program, "describeLabel")!; + const firstResult = closureCaptures(describeLabel, scopes); + const freshScopes = analyzeScopes(program); + const freshResult = closureCaptures(describeLabel, freshScopes); + expect(freshResult).not.toBe(firstResult); + expect(capturedNames(freshResult)).toEqual(capturedNames(firstResult)); + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.ts b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.ts index 0f1047fcaf..e5eb4cd851 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/semantic/closure-captures.ts @@ -2,21 +2,10 @@ import type { EsTreeNode } from "../utils/es-tree-node.js"; import type { ReferenceDescriptor, ScopeAnalysis } from "./scope-analysis.js"; import { isDescendantScope } from "./scope-analysis.js"; import { TYPE_POSITION_CHILD_KEYS } from "../constants/ts-type-position-keys.js"; -import { isAstDescendant } from "../utils/is-ast-descendant.js"; import { isAstNode } from "../utils/is-ast-node.js"; import { isFunctionLike } from "../utils/is-function-like.js"; -// True if `inner` is a descendant of `outer` (or equal) in the AST -// tree. Used to filter references inside `functionNode`. -// Returns every reference inside `functionNode`'s body whose binding -// lives OUTSIDE the function — i.e. the closure-captured set. Useful -// for exhaustive-deps to compute the actual set of values a hook -// callback closes over. -// -// Excludes: globals (unresolved references), references whose binding -// is the function itself (recursive call) or its parameters / -// internal locals. -export const closureCaptures = ( +const computeClosureCaptures = ( functionNode: EsTreeNode, scopes: ScopeAnalysis, ): ReadonlyArray => { @@ -72,7 +61,44 @@ export const closureCaptures = ( }; visit(functionNode); - // Filter out references whose identifier is OUTSIDE functionNode in - // the AST (defensive — shouldn't happen given our walk). - return out.filter((reference) => isAstDescendant(reference.identifier, functionNode)); + // Every collected reference's identifier IS a walked descendant of + // `functionNode` (`referenceFor` is keyed by the identifier node, and + // bubbled inner captures sit inside inner subtrees), so no + // containment re-check is needed here. + return out; +}; + +// Memoized per (ScopeAnalysis, function node). The walk recurses into +// inner functions through this entry point, so nested callbacks compute +// once and every enclosing function — and every calling rule — reuses +// the shared frozen-by-convention array (`ReadonlyArray`, callers only +// iterate). Keyed on the ScopeAnalysis first because the semantic- +// context fallback can mint throwaway stub analyses for the same AST. +const capturesByAnalysis = new WeakMap< + ScopeAnalysis, + WeakMap> +>(); + +// Returns every reference inside `functionNode`'s body whose binding +// lives OUTSIDE the function — i.e. the closure-captured set. Useful +// for exhaustive-deps to compute the actual set of values a hook +// callback closes over. +// +// Excludes: globals (unresolved references), references whose binding +// is the function itself (recursive call) or its parameters / +// internal locals. +export const closureCaptures = ( + functionNode: EsTreeNode, + scopes: ScopeAnalysis, +): ReadonlyArray => { + let capturesByFunction = capturesByAnalysis.get(scopes); + if (!capturesByFunction) { + capturesByFunction = new WeakMap(); + capturesByAnalysis.set(scopes, capturesByFunction); + } + const memoizedCaptures = capturesByFunction.get(functionNode); + if (memoizedCaptures) return memoizedCaptures; + const computedCaptures = computeClosureCaptures(functionNode, scopes); + capturesByFunction.set(functionNode, computedCaptures); + return computedCaptures; }; From a1c8ee110e137bbc8771c8a471c20287cccd2b38 Mon Sep 17 00:00:00 2001 From: Ray Arayilakath Date: Fri, 3 Jul 2026 17:59:25 -0500 Subject: [PATCH 02/33] perf(plugin): index line starts once per content in security-scan locations (#1040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLocationAtIndex sliced and split the whole content prefix on every call — O(content) per regex match on the synchronous, event-loop-blocking security scan. Build a per-content line-start table once and answer each query with a binary search. Co-authored-by: Claude Fable 5 --- .changeset/perf-security-scan-line-index.md | 5 + .../utils/get-location-at-index.test.ts | 123 ++++++++++++++++++ .../utils/get-location-at-index.ts | 51 +++++++- 3 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 .changeset/perf-security-scan-line-index.md create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-location-at-index.test.ts diff --git a/.changeset/perf-security-scan-line-index.md b/.changeset/perf-security-scan-line-index.md new file mode 100644 index 0000000000..a8e5975688 --- /dev/null +++ b/.changeset/perf-security-scan-line-index.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +perf: replace the security scan's per-match O(content) slice+split in `getLocationAtIndex` with a memoized per-content line-start index answered by binary search diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-location-at-index.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-location-at-index.test.ts new file mode 100644 index 0000000000..679dd4c1c9 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-location-at-index.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { SourceLocation } from "./get-location-at-index.js"; +import { getLocationAtIndex } from "./get-location-at-index.js"; + +// Reference: the previous slice+split implementation whose outputs are the +// pinned contract (line/column are 1-based; `\r?\n` is the line separator). +const referenceLocationAtIndex = (content: string, matchIndex: number): SourceLocation => { + if (matchIndex < 0) return { line: 1, column: 1 }; + const prefix = content.slice(0, matchIndex); + const lines = prefix.split(/\r?\n/); + return { + line: lines.length, + column: (lines[lines.length - 1]?.length ?? 0) + 1, + }; +}; + +describe("security-scan/utils/get-location-at-index", () => { + it("reports line 1 column 1 at index 0", () => { + expect(getLocationAtIndex("hello world", 0)).toEqual({ line: 1, column: 1 }); + }); + + it("reports line 1 column 1 for a negative index", () => { + expect(getLocationAtIndex("hello world", -1)).toEqual({ line: 1, column: 1 }); + }); + + it("reports line 1 column 1 for a NaN index", () => { + expect(getLocationAtIndex("ab\ncd", Number.NaN)).toEqual({ line: 1, column: 1 }); + }); + + it("reports a 1-based column within the first line", () => { + expect(getLocationAtIndex("hello world", 6)).toEqual({ line: 1, column: 7 }); + }); + + it("locates an index on a later LF line", () => { + expect(getLocationAtIndex("ab\ncd\nef", 4)).toEqual({ line: 2, column: 2 }); + }); + + it("treats the index of a bare \\n as the end of its line", () => { + expect(getLocationAtIndex("ab\ncd", 2)).toEqual({ line: 1, column: 3 }); + }); + + it("starts a new line right after a bare \\n", () => { + expect(getLocationAtIndex("ab\ncd", 3)).toEqual({ line: 2, column: 1 }); + }); + + it("locates an index after a \\r\\n separator", () => { + expect(getLocationAtIndex("ab\r\ncd", 5)).toEqual({ line: 2, column: 2 }); + }); + + it("treats the index of the \\r in \\r\\n as the end of its line", () => { + expect(getLocationAtIndex("ab\r\ncd", 2)).toEqual({ line: 1, column: 3 }); + }); + + it("counts the \\r as a column when the index sits on the \\n of \\r\\n", () => { + expect(getLocationAtIndex("ab\r\ncd", 3)).toEqual({ line: 1, column: 4 }); + }); + + it("starts a new line right after a \\r\\n separator", () => { + expect(getLocationAtIndex("ab\r\ncd", 4)).toEqual({ line: 2, column: 1 }); + }); + + it("does not treat a lone \\r as a line separator", () => { + expect(getLocationAtIndex("ab\rcd", 4)).toEqual({ line: 1, column: 5 }); + }); + + it("locates the end of content", () => { + expect(getLocationAtIndex("ab\ncd", 5)).toEqual({ line: 2, column: 3 }); + }); + + it("locates the end of content after a trailing \\n", () => { + expect(getLocationAtIndex("ab\n", 3)).toEqual({ line: 2, column: 1 }); + }); + + it("clamps a past-end index to the end of content", () => { + expect(getLocationAtIndex("ab\ncd", 50)).toEqual({ line: 2, column: 3 }); + expect(getLocationAtIndex("hello world", 50)).toEqual({ line: 1, column: 12 }); + }); + + it("handles empty content", () => { + expect(getLocationAtIndex("", 0)).toEqual({ line: 1, column: 1 }); + expect(getLocationAtIndex("", 5)).toEqual({ line: 1, column: 1 }); + }); + + it("handles consecutive newlines", () => { + expect(getLocationAtIndex("a\n\nb", 2)).toEqual({ line: 2, column: 1 }); + expect(getLocationAtIndex("a\n\nb", 3)).toEqual({ line: 3, column: 1 }); + }); + + it("handles mixed \\r\\n and \\n separators in one content", () => { + expect(getLocationAtIndex("a\r\nb\nc\r\nd", 8)).toEqual({ line: 4, column: 1 }); + }); + + it("stays correct when queries alternate between contents", () => { + const firstContent = "ab\ncd\nef"; + const secondContent = "one\r\ntwo\r\nthree"; + expect(getLocationAtIndex(firstContent, 7)).toEqual({ line: 3, column: 2 }); + expect(getLocationAtIndex(secondContent, 6)).toEqual({ line: 2, column: 2 }); + expect(getLocationAtIndex(firstContent, 7)).toEqual({ line: 3, column: 2 }); + expect(getLocationAtIndex(secondContent, 12)).toEqual({ line: 3, column: 3 }); + }); + + it("matches the reference implementation on seeded pseudo-random contents", () => { + let seedState = 0x2f6e2b1; + const nextRandom = (): number => { + seedState = (seedState * 48271) % 0x7fffffff; + return seedState / 0x7fffffff; + }; + const alphabet = ["a", "b", " ", "\n", "\r", "\r\n", "\t", "é", "x\ny"]; + for (let contentRound = 0; contentRound < 60; contentRound += 1) { + const pieceCount = Math.floor(nextRandom() * 40); + let content = ""; + for (let pieceIndex = 0; pieceIndex < pieceCount; pieceIndex += 1) { + content += alphabet[Math.floor(nextRandom() * alphabet.length)]; + } + for (let queryRound = 0; queryRound < 25; queryRound += 1) { + const matchIndex = Math.floor(nextRandom() * (content.length + 3)) - 1; + expect(getLocationAtIndex(content, matchIndex)).toEqual( + referenceLocationAtIndex(content, matchIndex), + ); + } + } + }); +}); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-location-at-index.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-location-at-index.ts index 4d49530912..8c830fb570 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-location-at-index.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/security-scan/utils/get-location-at-index.ts @@ -3,13 +3,52 @@ export interface SourceLocation { readonly column: number; } -// O(content) per call — memoizing line offsets is a tracked follow-up. +interface ContentLineIndex { + readonly content: string; + readonly lineStartOffsets: ReadonlyArray; +} + +// Single-entry memo: the security scan is synchronous and processes one +// file's content at a time (per-match exec loops query the same string +// consecutively), so caching the last content's line index is enough and +// stays bounded — it holds at most one content string reference. +let lastContentLineIndex: ContentLineIndex | undefined; + +const buildLineStartOffsets = (content: string): number[] => { + const lineStartOffsets = [0]; + for ( + let newlineIndex = content.indexOf("\n"); + newlineIndex !== -1; + newlineIndex = content.indexOf("\n", newlineIndex + 1) + ) { + lineStartOffsets.push(newlineIndex + 1); + } + return lineStartOffsets; +}; + +const getLineStartOffsets = (content: string): ReadonlyArray => { + if (lastContentLineIndex?.content === content) return lastContentLineIndex.lineStartOffsets; + const lineStartOffsets = buildLineStartOffsets(content); + lastContentLineIndex = { content, lineStartOffsets }; + return lineStartOffsets; +}; + export const getLocationAtIndex = (content: string, matchIndex: number): SourceLocation => { - if (matchIndex < 0) return { line: 1, column: 1 }; - const prefix = content.slice(0, matchIndex); - const lines = prefix.split(/\r?\n/); + if (Number.isNaN(matchIndex) || matchIndex < 0) return { line: 1, column: 1 }; + const lineStartOffsets = getLineStartOffsets(content); + const boundedIndex = Math.min(Math.trunc(matchIndex), content.length); + let lowLineIndex = 0; + let highLineIndex = lineStartOffsets.length - 1; + while (lowLineIndex < highLineIndex) { + const midLineIndex = (lowLineIndex + highLineIndex + 1) >> 1; + if (lineStartOffsets[midLineIndex] <= boundedIndex) { + lowLineIndex = midLineIndex; + } else { + highLineIndex = midLineIndex - 1; + } + } return { - line: lines.length, - column: (lines[lines.length - 1]?.length ?? 0) + 1, + line: lowLineIndex + 1, + column: boundedIndex - lineStartOffsets[lowLineIndex] + 1, }; }; From 5fec491e6844d73f658f355ae2cbe86285068f0e Mon Sep 17 00:00:00 2001 From: Ray Arayilakath Date: Fri, 3 Jul 2026 17:59:28 -0500 Subject: [PATCH 03/33] perf(plugin): memoize getElementType per JSX opening element across a11y rules (#1041) ~30 a11y rules resolve the same opening element once per rule; cache the result per node with a settings-identity guard (both hosts hand every rule the same per-file settings object) and memoize the jsx-a11y settings block per settings object. Co-authored-by: Claude Fable 5 --- .changeset/perf-get-element-type-memo.md | 5 + .../src/plugin/utils/get-element-type.test.ts | 94 +++++++++++++++++++ .../src/plugin/utils/get-element-type.ts | 66 ++++++++++--- 3 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 .changeset/perf-get-element-type-memo.md create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/get-element-type.test.ts diff --git a/.changeset/perf-get-element-type-memo.md b/.changeset/perf-get-element-type-memo.md new file mode 100644 index 0000000000..1029d29c3c --- /dev/null +++ b/.changeset/perf-get-element-type-memo.md @@ -0,0 +1,5 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +perf: memoize getElementType per JSX opening element (with a settings-identity guard) so the ~30 a11y rules resolve each element once instead of once per rule diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-element-type.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-element-type.test.ts new file mode 100644 index 0000000000..e317ba1db3 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/get-element-type.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseFixture } from "../../test-utils/parse-fixture.js"; +import type { EsTreeNode } from "./es-tree-node.js"; +import type { EsTreeNodeOfType } from "./es-tree-node-of-type.js"; +import { getElementType } from "./get-element-type.js"; +import { isNodeOfType } from "./is-node-of-type.js"; +import { walkAst } from "./walk-ast.js"; + +const parseOpeningElement = (jsx: string): EsTreeNodeOfType<"JSXOpeningElement"> => { + const { program, errors } = parseFixture(`const rendered = ${jsx};`); + expect(errors).toEqual([]); + let openingElement: EsTreeNodeOfType<"JSXOpeningElement"> | null = null; + walkAst(program, (child: EsTreeNode) => { + if (openingElement) return false; + if (isNodeOfType(child, "JSXOpeningElement")) openingElement = child; + }); + if (!openingElement) throw new Error("fixture has no JSX opening element"); + return openingElement; +}; + +describe("getElementType", () => { + it("resolves intrinsic elements to their tag name", () => { + expect(getElementType(parseOpeningElement("
"), undefined)).toBe("div"); + expect(getElementType(parseOpeningElement(""), undefined)).toBe("img"); + }); + + it("resolves custom components to their identifier name", () => { + expect(getElementType(parseOpeningElement("