diff --git a/apps/oxlint/src-js/plugins/index.ts b/apps/oxlint/src-js/plugins/index.ts index f1e62d8197567..05d4dfbaf0849 100644 --- a/apps/oxlint/src-js/plugins/index.ts +++ b/apps/oxlint/src-js/plugins/index.ts @@ -1,3 +1,6 @@ +// Patch `WeakMap` before any plugins are loaded +import "./weak_map.ts"; + export { lintFile } from "./lint.ts"; export { loadPlugin } from "./load.ts"; export { setupRuleConfigs } from "./config.ts"; diff --git a/apps/oxlint/src-js/plugins/lint.ts b/apps/oxlint/src-js/plugins/lint.ts index 6c77415e6c2ff..720c637c0729b 100644 --- a/apps/oxlint/src-js/plugins/lint.ts +++ b/apps/oxlint/src-js/plugins/lint.ts @@ -9,6 +9,7 @@ import { HAS_BOM_FLAG_POS } from "../generated/constants.ts"; import { typeAssertIs, debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts"; import { getErrorMessage } from "../utils/utils.ts"; import { setGlobalsForFile, resetGlobals } from "./globals.ts"; +import { resetWeakMaps } from "./weak_map.ts"; import { switchWorkspace } from "./workspace.ts"; import { addVisitorToCompiled, @@ -310,6 +311,7 @@ export function resetFile() { resetSourceAndAst(); resetSettings(); resetGlobals(); + resetWeakMaps(); } /** diff --git a/apps/oxlint/src-js/plugins/weak_map.ts b/apps/oxlint/src-js/plugins/weak_map.ts new file mode 100644 index 0000000000000..2cb75999312cd --- /dev/null +++ b/apps/oxlint/src-js/plugins/weak_map.ts @@ -0,0 +1,226 @@ +/** + * Patch `WeakMap`, to emulate how a `WeakMap` keyed by `context.sourceCode` would behave if every file + * had a different value for `context.sourceCode` (as it does in ESLint). + * + * Oxlint differs from ESLint in that `context.sourceCode` is always the singleton `SOURCE_CODE`, + * which is constant across all rules and files. + * + * This breaks plugins which use `WeakMap`s keyed by `context.sourceCode` to store data for each file, + * shared between different rules, as they rely on `sourceCode` being different for every file. + * This patch to `WeakMap` solves that problem. + * + * See: https://github.com/oxc-project/oxc/issues/20700 + */ + +import { SOURCE_CODE } from "./source_code.ts"; +import { debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts"; + +/** + * Entry in `trackedWeakMaps` array representing a `WeakMap` which has been used with `SOURCE_CODE` as key. + */ +interface TrackedWeakMap { + // `WeakRef` containing `WeakMap` instance + ref: WeakRef>; + // Index of this entry in `trackedWeakMaps` array + index: number; +} + +// `WeakMap`s which have been used with `SOURCE_CODE` as key. +const trackedWeakMaps: TrackedWeakMap[] = []; + +// `FinalizationRegistry` to remove entries from `trackedWeakMaps` array when the `WeakMap` they hold +// is garbage collected. +const registry = new FinalizationRegistry((entryToRemove) => { + // Remove `entryToRemove` from array using the same method as Rust's `Vec::swap_remove`. + // + // * If the element we want to remove is the last one, just pop it off the array. + // * Otherwise, pop last element, and overwrite the element we're removing with it. + // + // This avoids having to shuffle up all entries when an entry is removed. + // Each element stores its index in `trackedWeakMaps` array inline, to avoid needing to search the whole array + // to find the element we want to remove. + // Cost of this whole operation is constant, regardless of how many `WeakMap`s are tracked. + const lastEntry = trackedWeakMaps.pop(); + debugAssertIsNonNull(lastEntry, "`trackedWeakMaps` should not be empty"); + debugAssert(lastEntry.index === trackedWeakMaps.length, "Incorrect `index` for last entry"); + + if (lastEntry !== entryToRemove) { + const { index } = entryToRemove; + debugAssert(trackedWeakMaps[index] === entryToRemove, "Entry is in wrong position"); + lastEntry.index = index; + trackedWeakMaps[index] = lastEntry; + } +}); + +let resetWeakMapsFn: () => void; + +/** + * Patched `WeakMap` class, which replaces native `WeakMap` class. + * + * This is a subclass of native `WeakMap` class which emulates how a `WeakMap` keyed by `context.sourceCode` would + * behave if every file had a different value for `context.sourceCode`. + * + * It alters all methods to behave differently when `key` is `SOURCE_CODE` singleton. + * + * The value set for `SOURCE_CODE` is stored in `#value` field, and `#valueIsSet` is set to `true` when a value + * has been set. + * + * The `WeakMap` is added to `trackedWeakMaps` array. + * + * When a file completes linting, `lintFile` calls `resetWeakMaps`, which loops through all `WeakMap`s which have + * been used with `SOURCE_CODE` as key (`trackedWeakMaps` array), and resets their `#value` and `#valueIsSet` fields. + * This means that the next time `map.get(SOURCE_CODE)` is called, it will return `undefined`. + * + * To avoid `trackedWeakMaps` array growing indefinitely and holding on to `WeakMap`s which are no longer referenced + * anywhere else, `WeakMap`s are stored wrapped in `WeakRef`s, and removed from `trackedWeakMaps` array when + * the `WeakMap`s are garbage collected, by the `FinalizationRegistry` defined above. + * + * When key is anything other than `SOURCE_CODE`, the `WeakMap` behaves normally. + */ +class PatchedWeakMap extends WeakMap { + // Value set for `SOURCE_CODE` key for this file. + #value: Value | undefined; + + // `true` if a value has been set for `SOURCE_CODE` key for this file. + #valueIsSet: boolean = false; + + // `true` if this `WeakMap` has been used with `SOURCE_CODE` as key, has been added to `trackedWeakMaps` array, + // and registered with the `FinalizationRegistry`. + #isTracked: boolean = false; + + constructor(entries?: Iterable | null) { + // Pass no entries to `super()`. The native `WeakMap` constructor calls `this.set()` for each entry, + // but private fields are not initialized until after `super()` returns, so `this.set()` would fail + // if the entry's key is `SOURCE_CODE`. Instead, insert entries ourselves after construction. + super(); + + if (entries != null) { + for (const [key, value] of entries) { + if (key === SOURCE_CODE) { + this.#setSourceCodeValue(value); + } else { + super.set(key, value); + } + } + } + } + + has(key: Key): boolean { + if (key === SOURCE_CODE) return this.#valueIsSet; + return super.has(key); + } + + get(key: Key): Value | undefined { + if (key === SOURCE_CODE) { + return this.#valueIsSet === true ? this.#value : undefined; + } + + return super.get(key); + } + + set(key: Key, value: Value): this { + if (key === SOURCE_CODE) { + this.#setSourceCodeValue(value); + return this; + } + + return super.set(key, value); + } + + delete(key: Key): boolean { + if (key === SOURCE_CODE) { + const valueWasSet = this.#valueIsSet; + this.#value = undefined; + this.#valueIsSet = false; + return valueWasSet; + } + + return super.delete(key); + } + + // `getOrInsert` is not supported in NodeJS at present (March 2026), but presumably it will be in future. + // So we want to add this method, to support plugins which rely on it in future. + // But we have to implement it manually, rather than delegating to `super.getOrInsert`. + getOrInsert(key: Key, value: Value): Value { + if (key === SOURCE_CODE) { + if (this.#valueIsSet === true) return this.#value!; + + this.#setSourceCodeValue(value); + return value; + } + + if (super.has(key)) return super.get(key)!; + super.set(key, value); + return value; + } + + // `getOrInsertComputed` is not supported in NodeJS at present (March 2026), but presumably it will be in future. + // So we want to add this method, to support plugins which rely on it in future. + // But we have to implement it manually, rather than delegating to `super.getOrInsertComputed`. + getOrInsertComputed(key: Key, getValue: (key: Key) => Value): Value { + if (key === SOURCE_CODE) { + if (this.#valueIsSet === true) return this.#value!; + + const value = getValue(key); + this.#setSourceCodeValue(value); + return value; + } + + if (super.has(key)) return super.get(key)!; + const value = getValue(key); + super.set(key, value); + return value; + } + + /** + * Set value for `SOURCE_CODE` key for this file. + */ + #setSourceCodeValue(value: Value): void { + // Set value + this.#value = value; + this.#valueIsSet = true; + + // If this `WeakMap` wasn't already added to `trackedWeakMaps` array, add it now, wrapped in a `WeakRef`. + // Register it with the `FinalizationRegistry`, so the entry is removed from `trackedWeakMaps` array + // when the `WeakMap` is garbage collected. + if (this.#isTracked === false) { + const tracked = { + ref: new WeakRef(this), + index: trackedWeakMaps.length, + }; + trackedWeakMaps.push(tracked); + registry.register(this, tracked); + this.#isTracked = true; + } + } + + static { + /** + * Reset any `WeakMap`s which have been used with `SOURCE_CODE` as key. + * These `WeakMap`s will now return `false` for `map.has(SOURCE_CODE)` and `undefined` for `map.get(SOURCE_CODE)`. + * Called by `lintFile` after linting a file. + * This function is defined inside the class, so it can access private fields. + */ + resetWeakMapsFn = () => { + const trackedWeakMapsLen = trackedWeakMaps.length; + for (let i = 0; i < trackedWeakMapsLen; i++) { + const weakMap = trackedWeakMaps[i].ref.deref(); + if (weakMap !== undefined) { + weakMap.#value = undefined; + weakMap.#valueIsSet = false; + } + } + }; + } +} + +// Set class name to `WeakMap` so the patch is invisible to users. +// Note: We don't set name with `static name = "WeakMap";` in class body, +// because that makes the `name` property writable and enumerable. +Object.defineProperty(PatchedWeakMap, "name", { value: "WeakMap" }); + +// Replace global `WeakMap` with patched version +globalThis.WeakMap = PatchedWeakMap; + +// Export `resetWeakMaps` function for `lintFile` to use +export const resetWeakMaps: () => void = resetWeakMapsFn; diff --git a/apps/oxlint/test/fixtures/weakMaps/.oxlintrc.json b/apps/oxlint/test/fixtures/weakMaps/.oxlintrc.json new file mode 100644 index 0000000000000..852302ca7dc9b --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "jsPlugins": ["./plugin.ts"], + "categories": { "correctness": "off" }, + "rules": { + "weakmap-cache-plugin/cache1": "error", + "weakmap-cache-plugin/cache2": "error", + "weakmap-cache-plugin/cache3": "error" + } +} diff --git a/apps/oxlint/test/fixtures/weakMaps/eslint.config.js b/apps/oxlint/test/fixtures/weakMaps/eslint.config.js new file mode 100644 index 0000000000000..301b0a3f31ca0 --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/eslint.config.js @@ -0,0 +1,15 @@ +import plugin from "./plugin.ts"; + +export default [ + { + files: ["files/**/*.js"], + plugins: { + "weakmap-cache-plugin": plugin, + }, + rules: { + "weakmap-cache-plugin/cache1": "error", + "weakmap-cache-plugin/cache2": "error", + "weakmap-cache-plugin/cache3": "error", + }, + }, +]; diff --git a/apps/oxlint/test/fixtures/weakMaps/eslint.snap.md b/apps/oxlint/test/fixtures/weakMaps/eslint.snap.md new file mode 100644 index 0000000000000..64a5da3117566 --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/eslint.snap.md @@ -0,0 +1,53 @@ +# Exit code +1 + +# stdout +``` +/files/1.js + 1:1 error Rules which have accessed this file: +filename: /files/1.js +ident names: a, a, a +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache1 + 1:1 error Rules which have accessed this file: +filename: /files/1.js +ident names: a, a, a +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache2 + 1:1 error Rules which have accessed this file: +filename: /files/1.js +ident names: a, a, a +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache3 + +/files/2.js + 1:1 error Rules which have accessed this file: +filename: /files/2.js +ident names: b, b, b +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache1 + 1:1 error Rules which have accessed this file: +filename: /files/2.js +ident names: b, b, b +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache2 + 1:1 error Rules which have accessed this file: +filename: /files/2.js +ident names: b, b, b +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache3 + +/files/3.js + 1:1 error Rules which have accessed this file: +filename: /files/3.js +ident names: c, c, c +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache1 + 1:1 error Rules which have accessed this file: +filename: /files/3.js +ident names: c, c, c +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache2 + 1:1 error Rules which have accessed this file: +filename: /files/3.js +ident names: c, c, c +rule names: cache1, cache2, cache3 weakmap-cache-plugin/cache3 + +✖ 9 problems (9 errors, 0 warnings) +``` + +# stderr +``` +``` diff --git a/apps/oxlint/test/fixtures/weakMaps/files/1.js b/apps/oxlint/test/fixtures/weakMaps/files/1.js new file mode 100644 index 0000000000000..e9b43e86f148d --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/files/1.js @@ -0,0 +1 @@ +let a; diff --git a/apps/oxlint/test/fixtures/weakMaps/files/2.js b/apps/oxlint/test/fixtures/weakMaps/files/2.js new file mode 100644 index 0000000000000..623e5b2bc5d7d --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/files/2.js @@ -0,0 +1 @@ +let b; diff --git a/apps/oxlint/test/fixtures/weakMaps/files/3.js b/apps/oxlint/test/fixtures/weakMaps/files/3.js new file mode 100644 index 0000000000000..dfecaf7ec4921 --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/files/3.js @@ -0,0 +1 @@ +let c; diff --git a/apps/oxlint/test/fixtures/weakMaps/options.json b/apps/oxlint/test/fixtures/weakMaps/options.json new file mode 100644 index 0000000000000..f380a596b9d7a --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/options.json @@ -0,0 +1,3 @@ +{ + "eslint": true +} diff --git a/apps/oxlint/test/fixtures/weakMaps/output.snap.md b/apps/oxlint/test/fixtures/weakMaps/output.snap.md new file mode 100644 index 0000000000000..c6bdaf61cfeba --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/output.snap.md @@ -0,0 +1,93 @@ +# Exit code +1 + +# stdout +``` + x weakmap-cache-plugin(cache1): Rules which have accessed this file: + | filename: /files/1.js + | ident names: a, a, a + | rule names: cache1, cache2, cache3 + ,-[files/1.js:1:1] + 1 | let a; + : ^^^^^^^ + `---- + + x weakmap-cache-plugin(cache2): Rules which have accessed this file: + | filename: /files/1.js + | ident names: a, a, a + | rule names: cache1, cache2, cache3 + ,-[files/1.js:1:1] + 1 | let a; + : ^^^^^^^ + `---- + + x weakmap-cache-plugin(cache3): Rules which have accessed this file: + | filename: /files/1.js + | ident names: a, a, a + | rule names: cache1, cache2, cache3 + ,-[files/1.js:1:1] + 1 | let a; + : ^^^^^^^ + `---- + + x weakmap-cache-plugin(cache1): Rules which have accessed this file: + | filename: /files/2.js + | ident names: b, b, b + | rule names: cache1, cache2, cache3 + ,-[files/2.js:1:1] + 1 | let b; + : ^^^^^^^ + `---- + + x weakmap-cache-plugin(cache2): Rules which have accessed this file: + | filename: /files/2.js + | ident names: b, b, b + | rule names: cache1, cache2, cache3 + ,-[files/2.js:1:1] + 1 | let b; + : ^^^^^^^ + `---- + + x weakmap-cache-plugin(cache3): Rules which have accessed this file: + | filename: /files/2.js + | ident names: b, b, b + | rule names: cache1, cache2, cache3 + ,-[files/2.js:1:1] + 1 | let b; + : ^^^^^^^ + `---- + + x weakmap-cache-plugin(cache1): Rules which have accessed this file: + | filename: /files/3.js + | ident names: c, c, c + | rule names: cache1, cache2, cache3 + ,-[files/3.js:1:1] + 1 | let c; + : ^^^^^^^ + `---- + + x weakmap-cache-plugin(cache2): Rules which have accessed this file: + | filename: /files/3.js + | ident names: c, c, c + | rule names: cache1, cache2, cache3 + ,-[files/3.js:1:1] + 1 | let c; + : ^^^^^^^ + `---- + + x weakmap-cache-plugin(cache3): Rules which have accessed this file: + | filename: /files/3.js + | ident names: c, c, c + | rule names: cache1, cache2, cache3 + ,-[files/3.js:1:1] + 1 | let c; + : ^^^^^^^ + `---- + +Found 0 warnings and 9 errors. +Finished in Xms on 3 files with 3 rules using X threads. +``` + +# stderr +``` +``` diff --git a/apps/oxlint/test/fixtures/weakMaps/plugin.ts b/apps/oxlint/test/fixtures/weakMaps/plugin.ts new file mode 100644 index 0000000000000..c138357ddca2a --- /dev/null +++ b/apps/oxlint/test/fixtures/weakMaps/plugin.ts @@ -0,0 +1,82 @@ +// Test that Oxlint's `WeakMap` patch correctly isolates per-file state across multiple files. +// +// In ESLint, `context.sourceCode` is a different object for each file, so a `WeakMap` keyed by +// `context.sourceCode` naturally gives each file its own entry. +// +// In Oxlint, `context.sourceCode` is a singleton shared across all files and rules. +// Without the `WeakMap` patch (in `src-js/plugins/weak_map.ts`), data stored under the `sourceCode` key +// would leak between files. The patch intercepts `WeakMap` operations on the `sourceCode` singleton +// and resets the stored values between files (via `resetWeakMaps()` in `lint.ts`). +// +// This plugin creates 3 rules that share a `WeakMap` cache - a common pattern +// in real ESLint plugins. Each rule records its ID in the shared per-file state when it visits an `Identifier`. +// In `Program:exit`, the plugin reports the collected state. +// +// If the patch works correctly, each file gets its own fresh state, and the report for each file +// shows that file's own filename and exactly 3 rule names and 3 identifier names. +// +// If the patch is broken (data leaks between files), the second file would reuse the first file's +// state. The `identNames` and `ruleNames` arrays would have 6 entries instead of 3. + +import type { Plugin, Rule, SourceCode, Context } from "#oxlint/plugins"; + +interface PerFileState { + filename: string; + identNames: string[]; + ruleNames: string[]; +} + +const cache = new WeakMap(); + +function getCachedData(context: Context): PerFileState { + const cachedData = cache.get(context.sourceCode); + if (cachedData !== undefined) return cachedData; + + const data: PerFileState = { + filename: context.filename, + identNames: [], + ruleNames: [], + }; + cache.set(context.sourceCode, data); + return data; +} + +function createRuleUsingCache(): Rule { + return { + create(context) { + return { + Identifier(node) { + const data = getCachedData(context); + data.identNames.push(node.name); + data.ruleNames.push(context.id.slice(context.id.indexOf("/") + 1)); + }, + + "Program:exit"(node) { + const data = getCachedData(context); + + context.report({ + message: + "Rules which have accessed this file:\n" + + `filename: ${data.filename}\n` + + `ident names: ${data.identNames.join(", ")}\n` + + `rule names: ${data.ruleNames.sort().join(", ")}`, + node, + }); + }, + }; + }, + }; +} + +const plugin: Plugin = { + meta: { + name: "weakmap-cache-plugin", + }, + rules: { + cache1: createRuleUsingCache(), + cache2: createRuleUsingCache(), + cache3: createRuleUsingCache(), + }, +}; + +export default plugin; diff --git a/apps/oxlint/test/weak_map.isolated.test.ts b/apps/oxlint/test/weak_map.isolated.test.ts new file mode 100644 index 0000000000000..5aaa586258325 --- /dev/null +++ b/apps/oxlint/test/weak_map.isolated.test.ts @@ -0,0 +1,530 @@ +// Unit tests for the patched `WeakMap` class (defined in `src-js/plugins/weak_map.ts`). +// +// These tests verify that the patched `WeakMap` behaves identically to a native `WeakMap` +// when used with normal object keys (i.e. not the `SOURCE_CODE` singleton). +// +// This test file runs in a separate process (via Vitest's `forks` pool) to ensure the +// `globalThis.WeakMap` patch does not leak into other unit tests. + +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock `SOURCE_CODE` to avoid pulling in heavy dependencies from `source_code.ts`. +// The mock just needs to be a unique frozen object, same as the real `SOURCE_CODE`. +const SOURCE_CODE = Object.freeze({ __mock: true }); +vi.mock("../src-js/plugins/source_code.ts", () => ({ SOURCE_CODE })); + +// Get native `WeakMap` class before patching +const WeakMapOriginal = globalThis.WeakMap; + +// Import after mock is set up. This patches `globalThis.WeakMap`. +const { resetWeakMaps } = await import("../src-js/plugins/weak_map.ts"); + +it("`WeakMap` is patched", () => { + // Sanity check: The import should have replaced `globalThis.WeakMap` + expect(WeakMap).not.toBe(WeakMapOriginal); + expect(WeakMap).not.toBe(undefined); + + const weakMap = new WeakMap(); + expect(weakMap).toBeInstanceOf(WeakMap); + expect(weakMap).toBeInstanceOf(WeakMapOriginal); +}); + +// Test that all `WeakMap` methods work correctly for normal object keys, +// despite the global `WeakMap` class being patched +describe("`WeakMap` with normal keys", () => { + it("constructor with no params", () => { + const map = new WeakMap(); + expect(map).toBeInstanceOf(WeakMap); + }); + + it("constructor with `undefined`", () => { + const map = new WeakMap(undefined); + expect(map).toBeInstanceOf(WeakMap); + }); + + it("constructor with `null`", () => { + const map = new WeakMap(null); + expect(map).toBeInstanceOf(WeakMap); + }); + + it("constructor with empty array", () => { + const map = new WeakMap([]); + expect(map).toBeInstanceOf(WeakMap); + }); + + it("constructor with array", () => { + const key1 = {}; + const key2 = {}; + const map = new WeakMap([ + [key1, "a"], + [key2, "b"], + ]); + expect(map.has(key1)).toBe(true); + expect(map.has(key2)).toBe(true); + expect(map.get(key1)).toBe("a"); + expect(map.get(key2)).toBe("b"); + }); + + it("constructor with non-array iterable", () => { + const key1 = {}; + const key2 = {}; + function* entries(): Generator<[object, string]> { + yield [key1, "a"]; + yield [key2, "b"]; + } + const map = new WeakMap(entries()); + expect(map.has(key1)).toBe(true); + expect(map.has(key2)).toBe(true); + expect(map.get(key1)).toBe("a"); + expect(map.get(key2)).toBe("b"); + }); + + it("`has`", () => { + const map = new WeakMap(); + const key = {}; + expect(map.has(key)).toBe(false); + map.set(key, 42); + expect(map.has(key)).toBe(true); + }); + + it("`get` returns `undefined` for missing key", () => { + const map = new WeakMap(); + expect(map.get({})).toBe(undefined); + }); + + it("`set` and `get`", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "hello"); + expect(map.get(key)).toBe("hello"); + }); + + it("`set` overwrites existing value", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, 1); + map.set(key, 2); + expect(map.get(key)).toBe(2); + }); + + it("`set` returns the map (for chaining)", () => { + const map = new WeakMap(); + const key = {}; + expect(map.set(key, 1)).toBe(map); + }); + + it("`delete`", () => { + const map = new WeakMap(); + const key = {}; + expect(map.delete(key)).toBe(false); + map.set(key, "value"); + expect(map.delete(key)).toBe(true); + expect(map.has(key)).toBe(false); + expect(map.get(key)).toBe(undefined); + expect(map.delete(key)).toBe(false); + }); + + it("multiple keys are independent", () => { + const map = new WeakMap(); + const key1 = {}; + const key2 = {}; + map.set(key1, "a"); + map.set(key2, "b"); + expect(map.get(key1)).toBe("a"); + expect(map.get(key2)).toBe("b"); + }); + + it("`getOrInsert` returns existing value", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "existing"); + expect(map.getOrInsert(key, "new")).toBe("existing"); + expect(map.get(key)).toBe("existing"); + }); + + it("`getOrInsert` inserts and returns default when key is missing", () => { + const map = new WeakMap(); + const key = {}; + expect(map.getOrInsert(key, "default")).toBe("default"); + expect(map.get(key)).toBe("default"); + }); + + it("`getOrInsertComputed` returns existing value without calling callback", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "existing"); + const callback = vi.fn(() => "computed"); + expect(map.getOrInsertComputed(key, callback)).toBe("existing"); + expect(callback).not.toHaveBeenCalled(); + }); + + it("`getOrInsertComputed` calls callback with key and inserts result", () => { + const map = new WeakMap(); + const key = {}; + const callback = vi.fn((k: object) => `computed-${k === key}`); + expect(map.getOrInsertComputed(key, callback)).toBe("computed-true"); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(key); + expect(map.get(key)).toBe("computed-true"); + }); +}); + +// Test that the patched `WeakMap` correctly intercepts operations when the key is the `SOURCE_CODE` singleton, +// and that `resetWeakMaps` clears the stored values +describe("`WeakMap` with `SOURCE_CODE` key", () => { + afterEach(() => { + resetWeakMaps(); + }); + + it("constructor with `SOURCE_CODE` in initial entries", () => { + const map = new WeakMap([[SOURCE_CODE, "source"]]); + expect(map.has(SOURCE_CODE)).toBe(true); + expect(map.get(SOURCE_CODE)).toBe("source"); + }); + + it("constructor with `SOURCE_CODE` in non-array iterable", () => { + function* entries(): Generator<[object, string]> { + yield [SOURCE_CODE, "source"]; + } + const map = new WeakMap(entries()); + expect(map.has(SOURCE_CODE)).toBe(true); + expect(map.get(SOURCE_CODE)).toBe("source"); + }); + + it("constructor with `SOURCE_CODE` in initial entries is cleared by `resetWeakMaps`", () => { + const map = new WeakMap([[SOURCE_CODE, "source"]]); + resetWeakMaps(); + expect(map.has(SOURCE_CODE)).toBe(false); + expect(map.get(SOURCE_CODE)).toBe(undefined); + }); + + it("`has`", () => { + const map = new WeakMap(); + expect(map.has(SOURCE_CODE)).toBe(false); + map.set(SOURCE_CODE, 42); + expect(map.has(SOURCE_CODE)).toBe(true); + }); + + it("`get` returns `undefined` for missing key", () => { + const map = new WeakMap(); + expect(map.get(SOURCE_CODE)).toBe(undefined); + }); + + it("`set` and `get`", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "file-data"); + expect(map.get(SOURCE_CODE)).toBe("file-data"); + }); + + it("`set` overwrites existing value", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, 1); + map.set(SOURCE_CODE, 2); + expect(map.get(SOURCE_CODE)).toBe(2); + }); + + it("`set` returns the map (for chaining)", () => { + const map = new WeakMap(); + expect(map.set(SOURCE_CODE, 1)).toBe(map); + }); + + it("`delete`", () => { + const map = new WeakMap(); + expect(map.delete(SOURCE_CODE)).toBe(false); + map.set(SOURCE_CODE, "data"); + expect(map.delete(SOURCE_CODE)).toBe(true); + expect(map.has(SOURCE_CODE)).toBe(false); + expect(map.get(SOURCE_CODE)).toBe(undefined); + expect(map.delete(SOURCE_CODE)).toBe(false); + }); + + it("`getOrInsert` returns existing value", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "existing"); + expect(map.getOrInsert(SOURCE_CODE, "new")).toBe("existing"); + expect(map.get(SOURCE_CODE)).toBe("existing"); + }); + + it("`getOrInsert` inserts and returns default when key is missing", () => { + const map = new WeakMap(); + expect(map.getOrInsert(SOURCE_CODE, "default")).toBe("default"); + expect(map.has(SOURCE_CODE)).toBe(true); + expect(map.get(SOURCE_CODE)).toBe("default"); + }); + + it("`getOrInsertComputed` returns existing value without calling callback", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "existing"); + const callback = vi.fn(() => "computed"); + expect(map.getOrInsertComputed(SOURCE_CODE, callback)).toBe("existing"); + expect(callback).not.toHaveBeenCalled(); + expect(map.has(SOURCE_CODE)).toBe(true); + expect(map.get(SOURCE_CODE)).toBe("existing"); + }); + + it("`getOrInsertComputed` calls callback with key and inserts result", () => { + const map = new WeakMap(); + const callback = vi.fn((k: object) => `computed-${k === SOURCE_CODE}`); + expect(map.getOrInsertComputed(SOURCE_CODE, callback)).toBe("computed-true"); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(SOURCE_CODE); + expect(map.has(SOURCE_CODE)).toBe(true); + expect(map.get(SOURCE_CODE)).toBe("computed-true"); + }); +}); + +// Test that `SOURCE_CODE` and normal key entries do not interfere with each other within the same `WeakMap` +describe("`WeakMap` with mixed keys", () => { + afterEach(() => { + resetWeakMaps(); + }); + + it("constructor with `SOURCE_CODE` and normal keys in initial entries", () => { + const key = {}; + const map = new WeakMap([ + [key, "normal"], + [SOURCE_CODE, "source"], + ]); + expect(map.has(SOURCE_CODE)).toBe(true); + expect(map.get(SOURCE_CODE)).toBe("source"); + expect(map.has(key)).toBe(true); + expect(map.get(key)).toBe("normal"); + }); + + it("constructor with `SOURCE_CODE` in initial entries is cleared by `resetWeakMaps`", () => { + const key = {}; + const map = new WeakMap([ + [key, "normal"], + [SOURCE_CODE, "source"], + ]); + resetWeakMaps(); + expect(map.has(SOURCE_CODE)).toBe(false); + expect(map.get(SOURCE_CODE)).toBe(undefined); + expect(map.has(key)).toBe(true); + expect(map.get(key)).toBe("normal"); + }); + + it("`has` with normal key is unaffected by `SOURCE_CODE` entry", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "source"); + const key = {}; + expect(map.has(key)).toBe(false); + map.set(key, "normal"); + expect(map.has(key)).toBe(true); + map.delete(SOURCE_CODE); + expect(map.has(key)).toBe(true); + map.set(SOURCE_CODE, "source"); + expect(map.has(key)).toBe(true); + expect(map.delete(key)).toBe(true); + expect(map.has(key)).toBe(false); + }); + + it("`has` with `SOURCE_CODE` key is unaffected by normal entries", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "normal"); + expect(map.has(SOURCE_CODE)).toBe(false); + map.set(SOURCE_CODE, "source"); + expect(map.has(SOURCE_CODE)).toBe(true); + map.delete(key); + expect(map.has(SOURCE_CODE)).toBe(true); + }); + + it("`get` with normal key is unaffected by `SOURCE_CODE` entry", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "source"); + const key = {}; + expect(map.get(key)).toBe(undefined); + map.set(key, "normal"); + expect(map.get(key)).toBe("normal"); + map.delete(SOURCE_CODE); + expect(map.get(key)).toBe("normal"); + }); + + it("`get` with `SOURCE_CODE` key is unaffected by normal entries", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "normal"); + expect(map.get(SOURCE_CODE)).toBe(undefined); + map.set(SOURCE_CODE, "source"); + expect(map.get(SOURCE_CODE)).toBe("source"); + map.delete(key); + expect(map.get(SOURCE_CODE)).toBe("source"); + }); + + it("`set` with normal key does not affect `SOURCE_CODE` entry", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "source"); + const key = {}; + map.set(key, "normal"); + expect(map.get(SOURCE_CODE)).toBe("source"); + expect(map.get(key)).toBe("normal"); + map.delete(key); + expect(map.get(SOURCE_CODE)).toBe("source"); + map.set(SOURCE_CODE, "source2"); + expect(map.get(SOURCE_CODE)).toBe("source2"); + }); + + it("`set` with `SOURCE_CODE` key does not affect normal entries", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "normal"); + map.set(SOURCE_CODE, "source"); + expect(map.get(key)).toBe("normal"); + expect(map.get(SOURCE_CODE)).toBe("source"); + map.delete(SOURCE_CODE); + expect(map.get(key)).toBe("normal"); + map.set(key, "normal2"); + expect(map.get(key)).toBe("normal2"); + }); + + it("`delete` normal key does not affect `SOURCE_CODE` entry", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "normal"); + map.set(SOURCE_CODE, "source"); + map.delete(key); + expect(map.has(key)).toBe(false); + expect(map.has(SOURCE_CODE)).toBe(true); + expect(map.get(SOURCE_CODE)).toBe("source"); + }); + + it("`delete` `SOURCE_CODE` key does not affect normal entries", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "normal"); + map.set(SOURCE_CODE, "source"); + map.delete(SOURCE_CODE); + expect(map.has(SOURCE_CODE)).toBe(false); + expect(map.has(key)).toBe(true); + expect(map.get(key)).toBe("normal"); + }); + + it("`getOrInsert` with normal key is unaffected by `SOURCE_CODE` entry", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "source"); + const key = {}; + expect(map.getOrInsert(key, "default")).toBe("default"); + expect(map.get(key)).toBe("default"); + expect(map.get(SOURCE_CODE)).toBe("source"); + expect(map.getOrInsert(key, "altered")).toBe("default"); + }); + + it("`getOrInsert` with `SOURCE_CODE` key is unaffected by normal entries", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "normal"); + expect(map.getOrInsert(SOURCE_CODE, "default")).toBe("default"); + expect(map.get(SOURCE_CODE)).toBe("default"); + expect(map.get(key)).toBe("normal"); + expect(map.getOrInsert(SOURCE_CODE, "altered")).toBe("default"); + }); + + it("`getOrInsertComputed` with normal key is unaffected by `SOURCE_CODE` entry", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "source"); + const key = {}; + const callback = vi.fn((k: object) => `computed-${k === key}`); + expect(map.getOrInsertComputed(key, callback)).toBe("computed-true"); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(key); + expect(map.get(SOURCE_CODE)).toBe("source"); + }); + + it("`getOrInsertComputed` with `SOURCE_CODE` key is unaffected by normal entries", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "normal"); + const callback = vi.fn((k: object) => `computed-${k === SOURCE_CODE}`); + expect(map.getOrInsertComputed(SOURCE_CODE, callback)).toBe("computed-true"); + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(SOURCE_CODE); + expect(map.get(key)).toBe("normal"); + }); +}); + +// Additional tests for `resetWeakMaps` +describe("`resetWeakMaps`", () => { + afterEach(() => { + resetWeakMaps(); + }); + + it("clears `SOURCE_CODE` entries", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "file-data"); + resetWeakMaps(); + expect(map.has(SOURCE_CODE)).toBe(false); + expect(map.get(SOURCE_CODE)).toBe(undefined); + }); + + it("does not affect normal keys", () => { + const map = new WeakMap(); + const key = {}; + map.set(key, "normal"); + map.set(SOURCE_CODE, "source"); + resetWeakMaps(); + expect(map.has(key)).toBe(true); + expect(map.get(key)).toBe("normal"); + expect(map.has(SOURCE_CODE)).toBe(false); + expect(map.get(SOURCE_CODE)).toBe(undefined); + }); + + it("`SOURCE_CODE` key can be re-set after reset", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "file1"); + expect(map.get(SOURCE_CODE)).toBe("file1"); + resetWeakMaps(); + map.set(SOURCE_CODE, "file2"); + expect(map.get(SOURCE_CODE)).toBe("file2"); + }); + + it("clears multiple `WeakMap`s", () => { + const map1 = new WeakMap(); + const map2 = new WeakMap(); + map1.set(SOURCE_CODE, "data1"); + map2.set(SOURCE_CODE, 42); + resetWeakMaps(); + expect(map1.has(SOURCE_CODE)).toBe(false); + expect(map2.has(SOURCE_CODE)).toBe(false); + }); + + it("does not affect `WeakMap`s that have not used `SOURCE_CODE` key", () => { + const mapWithSourceCode = new WeakMap(); + const mapWithoutSourceCode = new WeakMap(); + const key = {}; + mapWithSourceCode.set(SOURCE_CODE, "source"); + mapWithoutSourceCode.set(key, "normal"); + resetWeakMaps(); + expect(mapWithoutSourceCode.has(key)).toBe(true); + expect(mapWithoutSourceCode.get(key)).toBe("normal"); + }); + + it("`getOrInsert` inserts fresh value after reset", () => { + const map = new WeakMap(); + map.getOrInsert(SOURCE_CODE, "first"); + resetWeakMaps(); + expect(map.getOrInsert(SOURCE_CODE, "second")).toBe("second"); + }); + + it("`getOrInsertComputed` calls callback again after reset", () => { + const map = new WeakMap(); + let callCount = 0; + const callback = () => `call-${++callCount}`; + expect(map.getOrInsertComputed(SOURCE_CODE, callback)).toBe("call-1"); + expect(map.getOrInsertComputed(SOURCE_CODE, callback)).toBe("call-1"); + resetWeakMaps(); + expect(map.getOrInsertComputed(SOURCE_CODE, callback)).toBe("call-2"); + }); + + it("calling `resetWeakMaps` with no tracked `WeakMap`s is a no-op", () => { + expect(() => resetWeakMaps()).not.toThrow(); + }); + + it("calling `resetWeakMaps` twice is safe", () => { + const map = new WeakMap(); + map.set(SOURCE_CODE, "data"); + resetWeakMaps(); + resetWeakMaps(); + expect(map.has(SOURCE_CODE)).toBe(false); + }); +}); diff --git a/apps/oxlint/vitest.config.ts b/apps/oxlint/vitest.config.ts index a036cbe8b8499..7f9cf7afba325 100644 --- a/apps/oxlint/vitest.config.ts +++ b/apps/oxlint/vitest.config.ts @@ -1,11 +1,30 @@ -import { configDefaults, defineConfig } from "vitest/config"; +import { defineConfig } from "vitest/config"; export default defineConfig({ - test: { - exclude: [...configDefaults.exclude, "fixtures/**"], - }, define: { DEBUG: "true", CONFORMANCE: "false", }, + test: { + projects: [ + { + extends: true, + test: { + name: "tests", + include: ["test/**/*.test.ts"], + exclude: ["test/**/*.isolated.test.ts"], + }, + }, + { + // Tests matching `*.isolated.test.ts` run in separate child processes (via `forks` pool) + // to prevent side effects (e.g. patching `globalThis.WeakMap`) from leaking into other unit tests. + extends: true, + test: { + name: "isolated", + include: ["test/**/*.isolated.test.ts"], + pool: "forks", + }, + }, + ], + }, });