Files
oxc-project__oxc/apps/oxlint/src-js/plugins/options.ts
overlookmotel 0861d9a5c8 fix(linter/plugins): remove getters from Context (#20115)
Fix an incompatibility with `eslint-plugin-jsdoc`. It relies on properties of `context` being values rather than getters:

https://github.com/gajus/eslint-plugin-jsdoc/blob/26276d428631a2625575607cba7875dab6797091/src/buildForbidRuleDefinition.js#L86-L103

When `options` property is a getter, this code throws an error because it attempts to define a property with both `value` and `get` in its descriptor.

This PR replaces the `id` and `options` getters with value properties.

The getters were there to prevent the `Context` object from being mutated by rule code, and allow the object to be frozen, as it is in ESLint. But each rule gets its own `Context` object, so if it chooses to mutate it, it only harms itself, and can't impact other rules/plugins. In ESLint, trying to alter the frozen `Context` object would be a hard error, so it's doubtful that any ESLint plugins do this anyway.

This is no real downside to this change for us, so we might as well make the change, and fix `eslint-plugin-jsdoc` and any other plugins which may depend on ESLint's behavior here.
2026-03-08 17:27:52 +00:00

401 lines
16 KiB
TypeScript

/*
* Options for rules.
*/
import assert from "node:assert";
import Ajv from "ajv";
import ajvPackageJson from "ajv/package.json" with { type: "json" };
import metaSchema from "ajv/lib/refs/json-schema-draft-04.json" with { type: "json" };
import { setCwd } from "./context.ts";
import { registeredRules } from "./load.ts";
import { deepCloneJsonValue, deepFreezeJsonArray } from "./json.ts";
import { currentWorkspace, switchWorkspace } from "./workspace.ts";
import { debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts";
import type { JSONSchema4 } from "json-schema";
import type { Writable } from "type-fest";
import type { JsonValue } from "./json.ts";
import type { RuleDetails } from "./load.ts";
// =================================================================================================
// Options types and constants
// =================================================================================================
/**
* Options for a rule on a file.
*/
export type Options = JsonValue[];
/**
* Schema describing valid options for a rule.
* `schema` property of `RuleMeta`.
*
* Can be one of:
* - `JSONSchema4` - Full JSON Schema object (must have `type: "array"` at root).
* - `JSONSchema4[]` - Array shorthand where each element describes corresponding options element.
* - `false` - Opts out of schema validation (not recommended).
*/
export type RuleOptionsSchema = JSONSchema4 | JSONSchema4[] | false;
/**
* Schema validator function.
* Does not return anything. Throws an error if validation fails.
* Should be passed full name of the rule, in form `<plugin>/<rule>`. This is used in error messages.
* `options` maybe be mutated if schema contains default values.
*/
export type SchemaValidator = (options: Options, ruleName: string) => void;
// Default rule options
export const DEFAULT_OPTIONS: Readonly<Options> = Object.freeze([]);
// All rule options.
// `lintFile` is called with an array of options IDs, which are indices into this array.
// First element is irrelevant - never accessed - because 0 index is a sentinel meaning default options.
// Set by `setOptions` at end of registering all plugins, and may also be changed when switching workspaces.
export let allOptions: Readonly<Options>[] | null = null;
/**
* Set `allOptions`. Used when switching workspaces.
* @param options - Array of options objects
*/
export function setAllOptions(options: Readonly<Options>[]) {
allOptions = options;
}
// Index into `allOptions` for default options
export const DEFAULT_OPTIONS_ID = 0;
// =================================================================================================
// Schema compilation
// =================================================================================================
// ESLint uses AJV v6.
// AJV v7 removed support for JSON Schema draft-04, which ESLint rule schemas use.
// We must stay on v6 to match ESLint's behavior.
debugAssert(
ajvPackageJson.version.startsWith("6."),
`AJV must be v6.x for JSON Schema draft-04 support, got ${ajvPackageJson.version}`,
);
// AJV instance configured to match ESLint's behavior.
// `useDefaults: true` applies schema `default` values to options during validation.
//
// Based on ESLint's AJV configuration:
// https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L15-L16
// https://github.com/eslint/eslint/blob/v9.39.2/lib/shared/ajv.js#L18-L34
const AJV = new Ajv({
meta: false,
useDefaults: true,
validateSchema: false,
missingRefs: "ignore",
verbose: true,
schemaId: "auto",
});
AJV.addMetaSchema(metaSchema);
// Ajv internal API
(AJV._opts as { defaultMeta: string }).defaultMeta = metaSchema.id;
/**
* Compile a rule's schema into a validator function.
*
* Returned validator function will throw if validation fails.
*
* This function should be called once when loading a rule, and the returned validator stored in `RuleDetails`.
*
* ESLint allows array shorthand: `schema: [item1, item2]` which means `options[0]` must match `item1`, etc.
* This function converts that to a proper JSON Schema, before compiling it.
*
* Based on ESLint's `getRuleOptionsSchema`:
* https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L177-L210
*
* @param schema - Rule's schema from `meta.schema`
* @returns Compiled AJV validator, or `null` if no options accepted, or `false` if validation is disabled
*/
export function compileSchema(
schema: RuleOptionsSchema | null | undefined,
): SchemaValidator | false | null {
// `null` or `undefined` means no validator provided. Rule does not accept any options.
// `false` means validation is disabled. Rule accepts any options.
if (schema == null) return null;
if (schema === false) return false;
// TODO: Does AJV already do this validation?
if (typeof schema !== "object") {
throw new TypeError("`rule.meta.schema` must be an array, object, or `false` if provided");
}
if (Array.isArray(schema)) {
// If schema is empty array, return `null`, indicating that rule does not accept any options
if (schema.length === 0) return null;
schema = {
type: "array",
items: schema,
minItems: 0,
maxItems: schema.length,
};
}
const validate = AJV.compile(schema);
return wrapSchemaValidator(validate);
}
/**
* Wrap AJV validator function to throw if validation fails.
* @param validate - AJV validator function
* @returns Wrapped validator function, which throws if validation fails
*/
function wrapSchemaValidator(validate: Ajv.ValidateFunction): SchemaValidator {
return (options: Options, ruleName: string): void => {
validate(options);
if (!validate.errors) return;
// Copied from https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L609-L634
// The code doesn't match AJV's types, but we use the same version of AJV as ESLint, so assuming it's OK.
// We cast `error` to `any` to avoid TS errors.
throw new Error(
`Options validation failed for rule '${ruleName}':\n` +
`Options:\n${JSON.stringify(options, null, 2)}\n` +
`Errors:\n` +
validate.errors
.map((error: any) => {
if (
error.keyword === "additionalProperties" &&
error.schema === false &&
typeof error.parentSchema?.properties === "object" &&
typeof error.params?.additionalProperty === "string"
) {
const expectedProperties = Object.keys(error.parentSchema.properties).map(
(property) => `"${property}"`,
);
return `\tValue ${JSON.stringify(error.data)} ${error.message}.\n\t\tUnexpected property "${error.params.additionalProperty}". Expected properties: ${expectedProperties.join(", ")}.`;
}
return `\tValue ${JSON.stringify(error.data)} ${error.message}.`;
})
.join("\n"),
);
};
}
// =================================================================================================
// Options processing
// =================================================================================================
/**
* Set all external rule options.
* Called once from Rust after config building, before any linting occurs.
* @param optionsJSON - Array of all rule options across all configurations, serialized as JSON
* @throws `Error` if options fail validation
*/
export function setOptions(optionsJson: string): void {
const details = JSON.parse(optionsJson);
allOptions = details.options;
debugAssertIsNonNull(allOptions, "`options` included in `optionsJSON` should not be null");
const { ruleIds, cwd } = details;
// Validate
if (DEBUG) {
assert(typeof cwd === "string", `cwd must be a string, got ${typeof cwd}`);
assert(Array.isArray(allOptions), `options must be an array, got ${typeof allOptions}`);
assert(Array.isArray(ruleIds), `ruleIds must be an array, got ${typeof ruleIds}`);
assert.strictEqual(
allOptions.length,
ruleIds.length,
"ruleIds and options arrays must be the same length",
);
for (const options of allOptions) {
assert(Array.isArray(options), `Elements of options must be arrays, got ${typeof options}`);
}
for (const ruleId of ruleIds) {
assert(
typeof ruleId === "number" && ruleId >= 0 && ruleId === Math.floor(ruleId),
`Elements of ruleIds must be non-negative integers, got ${ruleId}`,
);
}
}
// Switch to requested workspace.
// In CLI, `workspaceUri` is `null`, and there's only 1 workspace, so no need to switch.
// In LSP, there can be multiple workspaces, so we need to switch if we're not already in the right one.
const { workspaceUri } = details;
if (workspaceUri !== null) switchWorkspace(workspaceUri);
// Set CWD
setCwd(cwd);
// Process each options array.
// For each options array, merge with default options and apply schema defaults for the corresponding rule.
// Skip the first, as index 0 is a sentinel value meaning default options. First element is never accessed.
// `processOptions` also deep-freezes the options.
for (let i = 1, len = allOptions.length; i < len; i++) {
allOptions[i] = processOptions(
// `allOptions`' type is `Readonly`, but the array is mutable at present
allOptions[i] as Writable<(typeof allOptions)[number]>,
registeredRules[ruleIds[i]],
);
}
// Store `cwd` and `allOptions` in workspace
if (workspaceUri !== null) {
debugAssertIsNonNull(currentWorkspace, "`currentWorkspace` should be initialized");
currentWorkspace.cwd = cwd;
currentWorkspace.allOptions = allOptions;
}
}
/**
* Process user-provided options for a rule by applying the rule's defaults
* - merging with default options and applying schema defaults for the rule.
*
* Order of operations (matching ESLint's behavior):
* 1. Merge with `defaultOptions` (config options take precedence).
* 2. Apply schema defaults via AJV (fills in remaining gaps).
*
* This order ensures precedence: config > defaultOptions > schema defaults.
*
* ESLint calls `#normalizeRulesConfig()` first (merges `defaultOptions`), then `validateRulesConfig()` (AJV):
* https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L483-L484
* https://github.com/eslint/eslint/blob/v9.39.2/lib/config/config.js#L532-L637
*
* Returned options are deep frozen.
* `defaultOptions` must already be deep frozen before calling this function.
* `configOption` may be mutated.
*
* Default options merging follows the same logic as ESLint's `getRuleOptions`:
* https://github.com/eslint/eslint/blob/0f5a94a84beee19f376025c74f703f275d52c94b/lib/linter/linter.js#L443-L454
* https://github.com/eslint/eslint/blob/0f5a94a84beee19f376025c74f703f275d52c94b/lib/shared/deep-merge-arrays.js
*
* Notably, nested arrays are not merged - config options wins. e.g.:
* - Config options: [ [1] ]
* - Default options: [ [2, 3], 4 ]
* - Merged options: [ [1], 4 ]
*
* @param configOptions - Options from config (may be mutated by AJV)
* @param ruleDetails - Rule details
* @returns Processed options (deep frozen)
* @throws `Error` if options fail validation
*/
function processOptions(configOptions: Options, ruleDetails: RuleDetails): Readonly<Options> {
// Throw if no schema validator provided
const validator = ruleDetails.optionsSchemaValidator;
if (validator === null) {
throw new Error(`Rule '${ruleDetails.context.id}' does not accept options`);
}
// Merge with `defaultOptions` first
const { defaultOptions } = ruleDetails;
const options =
defaultOptions === DEFAULT_OPTIONS
? configOptions
: mergeOptions(configOptions, defaultOptions);
// Apply schema defaults (mutates `options` in place).
//
// AJV validation with `useDefaults: true` fills in default values from the schema.
// `mergeOptions` cloned `defaultOptions`, so mutations made by AJV validation won't affect `defaultOptions`
// (and `defaultOptions` is frozen anyway, so it can't be mutated).
// `configOptions` may be mutated, but that's OK, because we only use it once.
if (validator !== false) validator(options, ruleDetails.context.id);
deepFreezeJsonArray(options);
return options;
}
/**
* Merge user-provided options from config with rule's default options.
*
* Config options take precedence over default options.
*
* Options returned are entirely mutable. No parts are frozen, even parts which come from default options.
* Any parts of `defaultOptions` which are included are deep cloned.
* Any parts of `configOptions` which are included in return value are *not* cloned.
*
* @param configOptions - Options from config
* @param defaultOptions - Default options from `rule.meta.defaultOptions`
* @returns Merged options (mutable, not frozen)
*/
function mergeOptions(configOptions: Options, defaultOptions: Readonly<Options>): Options {
const merged: Options = [];
const defaultOptionsLength = defaultOptions.length,
configOptionsLength = configOptions.length,
bothLength = Math.min(defaultOptionsLength, configOptionsLength);
// Merge elements shared by both arrays
let i = 0;
for (; i < bothLength; i++) {
merged.push(mergeValues(configOptions[i], defaultOptions[i]));
}
// Take remaining elements from whichever array is longer
if (defaultOptionsLength > configOptionsLength) {
// `defaultOptions` has more elements - deep clone remaining elements
for (; i < defaultOptionsLength; i++) {
merged.push(deepCloneJsonValue(defaultOptions[i]));
}
} else {
// `configOptions` has more elements - just copy references (will be frozen later)
for (; i < configOptionsLength; i++) {
merged.push(configOptions[i]);
}
}
return merged;
}
/**
* Merge value from user-provided options with value from default options.
*
* Config value takes precedence over default value.
* Returns a mutable value (not frozen) - caller is responsible for freezing.
* `configValue` is mutated in place.
*
* @param configValue - Value from config
* @param defaultValue - Value from default options
* @returns Merged value (mutable)
*/
function mergeValues(configValue: JsonValue, defaultValue: JsonValue): JsonValue {
// If config value is a primitive or array, it wins
if (configValue === null || typeof configValue !== "object" || Array.isArray(configValue)) {
return configValue;
}
// If default value is a primitive or an array, config value wins (it's an object)
if (defaultValue === null || typeof defaultValue !== "object" || Array.isArray(defaultValue)) {
return configValue;
}
// Both are objects (not arrays) - merge `defaultValue` into `configValue`.
//
// Symbol properties and circular references are not possible in JSON, so no need to handle them here.
// `defaultValue` is from JSON, so we can use a simple `for..in` loop over `defaultValue`.
for (const key in defaultValue) {
// `Object.hasOwn` not `in`, in case `key` is `"__proto__"`
if (Object.hasOwn(configValue, key)) {
// Both have this key - recursively merge.
// `key` is an own property of both `configValue` and `defaultValue`.
// Therefore, we don't need special handling for if `key` is `"__proto__"`.
// All the property reads and writes here will affect only the owned properties of these objects,
// (including if those properties are named `"__proto__"`).
configValue[key] = mergeValues(configValue[key], defaultValue[key]);
} else {
// Only `defaultValue` has this key - deep clone and add to `configValue`.
// `Object.defineProperty` in case `key` is `"__proto__"`.
Object.defineProperty(configValue, key, {
value: deepCloneJsonValue(defaultValue[key]),
writable: true,
enumerable: true,
configurable: true,
});
}
}
return configValue;
}