Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/quiet-structured-clone-definitions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
"react-doctor": patch
---

Avoid recommending structuredClone inside a directly installed implementation of the same global API, including a narrowly recognized Jest mock implementation. Ordinary application cloning and unsupported provider or binding shapes remain reportable.

Keep self-definition provider checks conservative for erased TypeScript declarations while retaining genuine lexical values beside same-name types.

Retain clone warnings when Function-constructor member or destructuring escapes invalidate the closed global-provider proof.

Reuse immutable self-definition closure results across returned clone expressions to avoid repeated whole-program scans.
7 changes: 7 additions & 0 deletions .changeset/semantic-runtime-reference-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
"eslint-plugin-react-doctor": patch
"react-doctor": patch
---

Preserve runtime value references and writes when erased TypeScript declarations share their names, so provider exposure cannot bypass diagnostic proofs.
29 changes: 19 additions & 10 deletions packages/evals/tests/delete-vercel-snapshot-before-deadline.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { Snapshot } from "@vercel/sandbox";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { deleteVercelSnapshotBeforeDeadline } from "../src/utils/delete-vercel-snapshot-before-deadline.js";
const CONTROL_PLANE_TEST_TIMEOUT_MS = 5;
afterEach(() => vi.restoreAllMocks());
beforeEach(() => {
vi.useFakeTimers({ toFake: ["performance", "setTimeout", "clearTimeout"] });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

describe("deleteVercelSnapshotBeforeDeadline", () => {
it("recovers snapshots when the create response was lost", async () => {
Expand Down Expand Up @@ -32,13 +38,16 @@ describe("deleteVercelSnapshotBeforeDeadline", () => {
snapshotId: { value: "snapshot-name" },
delete: { value: () => new Promise<never>(() => undefined) },
});
await expect(
deleteVercelSnapshotBeforeDeadline({
credentials: {},
snapshotName: "builder",
snapshot,
deadlineMilliseconds: globalThis.performance.now() + CONTROL_PLANE_TEST_TIMEOUT_MS,
}),
).rejects.toThrow("Timed out deleting Vercel snapshot snapshot-name");
const deletion = deleteVercelSnapshotBeforeDeadline({
credentials: {},
snapshotName: "builder",
snapshot,
deadlineMilliseconds: globalThis.performance.now() + CONTROL_PLANE_TEST_TIMEOUT_MS,
});
const deletionAssertion = expect(deletion).rejects.toThrow(
"Timed out deleting Vercel snapshot snapshot-name",
);
await vi.advanceTimersByTimeAsync(CONTROL_PLANE_TEST_TIMEOUT_MS);
await deletionAssertion;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// rule: no-json-parse-stringify-clone
// weakness: library-idiom
// source: independently authored public synthetic
// verdict: fail
export const duplicateValue = (value) => JSON.parse(JSON.stringify(value));
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const propertyName = "constructor";
const { [propertyName]: execute } = () => {};
execute("global = {}")();
global.structuredClone = (value) => JSON.parse(JSON.stringify(value));
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
(() => {}).constructor("global = {}")();
global.structuredClone = (value) => JSON.parse(JSON.stringify(value));
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare const globalThis: typeof global;
globalThis.structuredClone = replacement;
global.structuredClone = (value) => JSON.parse(JSON.stringify(value));
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type globalThis = require("types");
globalThis.structuredClone = replacement;
global.structuredClone = (value) => JSON.parse(JSON.stringify(value));
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const Function = () => undefined;
interface Function {}
void Function;
global.structuredClone = (value) => JSON.parse(JSON.stringify(value));
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// rule: no-json-parse-stringify-clone
// verdict: fail
// weakness: provider-erasure
// source: independently synthetic constructor key beside erased type
const propertyName = "constructor";
interface propertyName {}
(() => {})[propertyName]("global = {}")();
global.structuredClone = (value) => JSON.parse(JSON.stringify(value));
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// rule: no-json-parse-stringify-clone
// weakness: library-idiom
// source: independently authored public synthetic
// verdict: pass
global.structuredClone = jest.fn(function (value) {
return JSON.parse(JSON.stringify(value));
});
2 changes: 2 additions & 0 deletions packages/fuzz/src/snippet-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,8 @@ export const JSX_LEAF_POOL = [
// Rare-but-parseable weirdness kept from the original generator, plus
// trace-mined oddities (unicode, globalThis gymnastics, labels).
export const EDGE_CASE_STATEMENT_POOL = [
`global.structuredClone = jest.fn(value => JSON.parse(JSON.stringify(value)));`,
`const copiedValue = JSON.parse(JSON.stringify(value));`,
`const contextCallback = config.onSelect; const effectiveCallback = onSelect ?? contextCallback; const derivedHandler = useCallback(() => effectiveCallback?.(), [effectiveCallback]);`,
`const membershipCandidates = [1, 2, 3, 4, 5, 6, 7, 8, 9]; const membershipValues = [1, 2, 3, 4, 5, 6, 7, 8, 9]; for (const membershipCandidate of membershipCandidates) { membershipValues.includes(membershipCandidate); }`,
`const numericMembershipValues: number[] = []; const numericMembershipAllowed: number[] = []; numericMembershipValues.reduce((count, value) => numericMembershipAllowed.indexOf(value) !== -1 ? count + 1 : count, 0);`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vite-plus/test";
import { runRule } from "../../../test-utils/run-rule.js";
import { noJsonParseStringifyClone } from "./no-json-parse-stringify-clone.js";
interface ConstructorExposureCase {
name: string;
code: string;
expectedCount: number;
}
const cases: ConstructorExposureCase[] = [
{
name: "direct constructor",
code: '(()=>{}).constructor("global = {}")();global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 1,
},
{
name: "computed constructor",
code: 'const propertyName="constructor";(()=>{})[propertyName]("global = {}")();global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 1,
},
{
name: "destructured constructor",
code: 'const {constructor:execute}=()=>{};execute("global = {}")();global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 1,
},
{
name: "computed destructured constructor",
code: 'const propertyName="constructor";const {[propertyName]:execute}=()=>{};execute("global = {}")();global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 1,
},
{
name: "constructor alias",
code: 'const execute=(()=>{}).constructor;execute("global = {}")();global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 1,
},
{
name: "unrelated computed member",
code: 'const propertyName="label";const record={label:"ready"};void record[propertyName];global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 0,
},
{
name: "unrelated computed pattern",
code: 'const propertyName="label";const {[propertyName]:label}={label:"ready"};void label;global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 0,
},
];
describe("structuredClone constructor escapes", () => {
it.each(cases)("$name", ({ code, expectedCount }) => {
const result = runRule(noJsonParseStringifyClone, code, {
filename: "src/constructor-exposure.tsx",
forceJsx: true,
});
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(expectedCount);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vite-plus/test";
import { runRule } from "../../../test-utils/run-rule.js";
import { noJsonParseStringifyClone } from "./no-json-parse-stringify-clone.js";

interface RuntimeBindingCase {
name: string;
code: string;
expectedCount: number;
}
const cases: RuntimeBindingCase[] = [
{
name: "native self definition",
code: "global.structuredClone = value => JSON.parse(JSON.stringify(value));",
expectedCount: 0,
},
{
name: "unmasked replacement",
code: "globalThis.structuredClone = replacement;global.structuredClone = value => JSON.parse(JSON.stringify(value));",
expectedCount: 1,
},
{
name: "ambient carrier replacement",
code: "declare const globalThis: typeof global;globalThis.structuredClone = replacement;global.structuredClone = value => JSON.parse(JSON.stringify(value));",
expectedCount: 1,
},
{
name: "erased import equals carrier",
code: 'import type globalThis = require("types");globalThis.structuredClone = replacement;global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 1,
},
{
name: "ambient dynamic constructor",
code: 'declare function Function(source: string): unknown; Function("global.structuredClone = replacement");global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expectedCount: 1,
},
{
name: "genuine local dynamic constructor",
code: "const Function = () => undefined; void Function;global.structuredClone = value => JSON.parse(JSON.stringify(value));",
expectedCount: 0,
},
{
name: "type after genuine local value",
code: "const Function = () => undefined; interface Function {} void Function;global.structuredClone = value => JSON.parse(JSON.stringify(value));",
expectedCount: 0,
},
{
name: "type before genuine local value",
code: "interface Function {} const Function = () => undefined; void Function;global.structuredClone = value => JSON.parse(JSON.stringify(value));",
expectedCount: 0,
},
{
name: "inner type outer local value",
code: "const Function = () => undefined; { interface Function {} void Function;global.structuredClone = value => JSON.parse(JSON.stringify(value));}",
expectedCount: 0,
},
{
name: "erased namespace carrier remains unsupported",
code: "namespace globalThis { export interface Carrier {} }globalThis.structuredClone = replacement;global.structuredClone = value => JSON.parse(JSON.stringify(value));",
expectedCount: 1,
},
];

describe("structuredClone self-definition runtime provider bindings", () => {
it.each(cases)("$name", ({ code, expectedCount }) => {
const result = runRule(noJsonParseStringifyClone, code, {
filename: "src/runtime-provider.tsx",
forceJsx: true,
});
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(expectedCount);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vite-plus/test";
import { runRule } from "../../../test-utils/run-rule.js";
import { noJsonParseStringifyClone } from "./no-json-parse-stringify-clone.js";

const scenarios = [
{
name: "interface after constructor key",
code: 'const propertyName="constructor";interface propertyName{};(()=>{})[propertyName]("global = {}")();global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expected: 1,
},
{
name: "interface before constructor key",
code: 'interface propertyName{};const propertyName="constructor";(()=>{})[propertyName]("global = {}")();global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expected: 1,
},
{
name: "type alias after template constructor key",
code: 'const propertyName=`constructor`;type propertyName=unknown;const {[propertyName]:execute}=()=>{};execute("global = {}")();global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expected: 1,
},
{
name: "inert computed key beside interface",
code: 'const propertyName="length";interface propertyName{};void (()=>{})[propertyName];global.structuredClone = value => JSON.parse(JSON.stringify(value));',
expected: 0,
},
];

describe("constructor keys beside erased types", () => {
for (const scenario of scenarios) {
it(scenario.name, () => {
const result = runRule(noJsonParseStringifyClone, scenario.code, {
filename: "src/constructor-key.tsx",
forceJsx: true,
});
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(scenario.expected);
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { runRule } from "../../../test-utils/run-rule.js";
import { noJsonParseStringifyClone } from "./no-json-parse-stringify-clone.js";
import {
STRUCTURED_CLONE_SMALL_BRANCH_COUNT,
STRUCTURED_CLONE_LARGE_BRANCH_COUNT,
} from "./utils/constants.js";

const traversalCounter = vi.hoisted(() =>
vi.fn<typeof import("../../utils/walk-ast.js").walkAst>(),
);
vi.mock("../../utils/walk-ast.js", async (importOriginal) => {
const originalModule = await importOriginal<typeof import("../../utils/walk-ast.js")>();
return {
...originalModule,
walkAst: traversalCounter.mockImplementation(originalModule.walkAst),
};
});

describe("structuredClone self-definition program admission work", () => {
it.each([
{ name: "closed", prefix: "", warningsPerBranch: 0 },
{ name: "replaced", prefix: "global.structuredClone=replacement;", warningsPerBranch: 1 },
])("$name", ({ prefix, warningsPerBranch }) => {
const measureProgramTraversals = (branchCount: number): number => {
const branches = Array.from(
{ length: branchCount },
(_, branchIndex) =>
`if(value.flag===${branchIndex})return JSON.parse(JSON.stringify(value));`,
).join("");
const code = `${prefix}global.structuredClone=value=>{${branches}};`;
traversalCounter.mockClear();
const result = runRule(noJsonParseStringifyClone, code, {
filename: "src/self-definition-work.tsx",
forceJsx: true,
});
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(branchCount * warningsPerBranch);
return traversalCounter.mock.calls.filter(([rootNode]) => rootNode.type === "Program").length;
};
const smallTraversalCount = measureProgramTraversals(STRUCTURED_CLONE_SMALL_BRANCH_COUNT);
const largeTraversalCount = measureProgramTraversals(STRUCTURED_CLONE_LARGE_BRANCH_COUNT);
expect(smallTraversalCount).toBeGreaterThan(0);
expect(largeTraversalCount).toBe(smallTraversalCount);
});
});
Loading
Loading