Files
oxc-project__oxc/apps/oxlint/src-js/plugins/context.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

564 lines
19 KiB
TypeScript

/*
* Context objects for rules.
*
* Context objects are in 2 layers:
* 1. Context object for each rule.
* 2. File context object, shared across all rules.
*
* This mirrors ESLint's `RuleContext` and `FileContext` types (with `RuleContext` inheriting from `FileContext`).
* Some ESLint plugins rely on this 2-layer structure. https://github.com/oxc-project/oxc/issues/15325
*
* The difference is that we don't create new file context and rule context objects for each file, but instead reuse
* the same objects over and over. After plugin loading is complete, no further `Context` objects are created.
* This reduces pressure on garbage collector, and is required to support `createOnce` API.
*
* ## Rule context
*
* Each rule has its own `Context` object. It is passed to that rule's `create` and `createOnce` functions.
* `Context` objects are created during plugin loading for each rule.
* For each file, the same `Context` object is reused over and over.
*
* ## File context
*
* All `Context` objects have `FILE_CONTEXT` as their prototype, which provides getters for file-specific properties.
* `FILE_CONTEXT` is a singleton object, shared across all rules.
* `FILE_CONTEXT` contains no state, only getters which return other singletons (`SOURCE_CODE`),
* and global variables (`filePath`, `settings`, `cwd`).
*/
import { ast, initAst, fileIsJsx, SOURCE_CODE } from "./source_code.ts";
import { report } from "./report.ts";
import { settings, initSettings } from "./settings.ts";
import visitorKeys from "../generated/keys.ts";
import { debugAssertIsNonNull } from "../utils/asserts.ts";
import { envs, globals, initGlobals } from "./globals.ts";
import { version as packageVersion } from "../../package.json" with { type: "json" };
import type { Globals, Envs } from "./globals.ts";
import type { RuleDetails } from "./load.ts";
import type { Options } from "./options.ts";
import type { Diagnostic } from "./report.ts";
import type { Settings } from "./settings.ts";
import type { SourceCode } from "./source_code.ts";
import type { ModuleKind, Program } from "../generated/types.d.ts";
// Absolute path of file being linted.
// When `null`, indicates that no file is currently being linted (in `createOnce`, or between linting files).
export let filePath: string | null = null;
// Current working directory for file being linted.
// Set by `setOptions` at end of registering all plugins, and may also be changed when switching workspaces.
export let cwd: string | null = null;
/**
* Set CWD. Used when switching workspaces.
* @param cwdInput - CWD
*/
export function setCwd(cwdInput: string) {
cwd = cwdInput;
}
/**
* Set up context for linting a file.
* @param filePathInput - Absolute path of file being linted
*/
export function setupFileContext(filePathInput: string): void {
filePath = filePathInput;
}
/**
* Reset file context.
*
* This disables all getters on `Context` objects, and `FILE_CONTEXT`.
* Only way user could trigger a getter if this wasn't done is to store a `Context` object, and then access one of its
* properties in next tick, in between linting files (highly unlikely). But it's cheap to do, so we cover this odd case.
*/
export function resetFileContext(): void {
filePath = null;
}
// ECMAScript version. This matches ESLint's default.
export const ECMA_VERSION = 2026;
const ECMA_VERSION_NUMBER = 17;
// Supported ECMAScript versions. This matches ESLint's default.
const SUPPORTED_ECMA_VERSIONS = Object.freeze([3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]);
// Singleton object for parser's `Syntax` property. Generated lazily.
let Syntax: Record<string, string> | null = null;
// Singleton object for parser.
const PARSER = Object.freeze({
/**
* Parser name.
*/
name: "oxlint",
/**
* Parser version.
*/
version: packageVersion,
/**
* Parse code into an AST.
* @param code - Code to parse
* @param options? - Parser options
* @returns AST
*/
// oxlint-disable-next-line no-unused-vars
parse(code: string, options?: Record<string, unknown>): Program {
throw new Error("`context.languageOptions.parser.parse` not implemented yet."); // TODO
},
/**
* Visitor keys for AST nodes.
*/
VisitorKeys: visitorKeys,
/**
* Ast node types.
*/
get Syntax(): Readonly<Record<string, string>> {
// Construct lazily, as it's probably rarely used
if (Syntax === null) {
Syntax = Object.create(null);
for (const key in visitorKeys) {
Syntax![key] = key;
}
Object.freeze(Syntax);
}
return Syntax!;
},
/**
* Latest ECMAScript version supported by parser.
*/
latestEcmaVersion: ECMA_VERSION_NUMBER,
/**
* ECMAScript versions supported by parser.
*/
supportedEcmaVersions: SUPPORTED_ECMA_VERSIONS,
});
// In conformance build, setting properties of this object to `true` or `false` overrides the defaults
export const ecmaFeaturesOverride: {
globalReturn: boolean | null;
impliedStrict: boolean | null;
} = {
globalReturn: null,
impliedStrict: null,
};
// Singleton object for ECMA features.
const ECMA_FEATURES = Object.freeze({
/**
* `true` if file was parsed as JSX.
*/
get jsx(): boolean {
return fileIsJsx();
},
/**
* `true` if file was parsed with top-level `return` statements allowed.
*/
get globalReturn(): boolean {
// TODO: Would be better to get `sourceType` without deserializing whole AST,
// in case it's used in `create` to return an empty visitor if wrong type.
if (ast === null) initAst();
debugAssertIsNonNull(ast);
// In conformance build, allow overriding from `languageOptions.parserOptions.ecmaFeatures.globalReturn` config
if (CONFORMANCE) {
const { globalReturn } = ecmaFeaturesOverride;
if (globalReturn !== null) return globalReturn;
}
return ast.sourceType === "commonjs";
},
/**
* `true` if file was parsed as strict mode code.
*/
get impliedStrict(): boolean {
// TODO: Would be better to get `sourceType` without deserializing whole AST,
// in case it's used in `create` to return an empty visitor if wrong type.
if (ast === null) initAst();
debugAssertIsNonNull(ast);
// In conformance build, allow overriding from `languageOptions.parserOptions.ecmaFeatures.impliedStrict` config
if (CONFORMANCE) {
const { impliedStrict } = ecmaFeaturesOverride;
if (impliedStrict !== null) return impliedStrict;
}
return ast.sourceType === "module";
},
});
// Singleton object for parser options.
// TODO: `sourceType` and `ecmaFeatures` are the only property ESLint provides.
// But does TS-ESLint provide any further properties?
const PARSER_OPTIONS = Object.freeze({
/**
* Source type of the file being linted.
*/
get sourceType(): ModuleKind {
// TODO: Would be better to get `sourceType` without deserializing whole AST,
// in case it's used in `create` to return an empty visitor if wrong type.
if (ast === null) initAst();
debugAssertIsNonNull(ast);
return ast.sourceType;
},
/**
* ECMA features.
*/
ecmaFeatures: ECMA_FEATURES,
});
// Singleton object for language options.
const LANGUAGE_OPTIONS = {
/**
* Source type of the file being linted.
*/
get sourceType(): ModuleKind {
// TODO: Would be better to get `sourceType` without deserializing whole AST,
// in case it's used in `create` to return an empty visitor if wrong type.
if (ast === null) initAst();
debugAssertIsNonNull(ast);
return ast.sourceType;
},
/**
* ECMAScript version of the file being linted.
*/
ecmaVersion: ECMA_VERSION,
/**
* Parser used to parse the file being linted.
*/
parser: PARSER,
/**
* Parser options used to parse the file being linted.
*/
// Note: If we change this implementation, also change `parserOptions` getter on `FILE_CONTEXT` below
parserOptions: PARSER_OPTIONS,
/**
* Globals defined for the file being linted.
*/
get globals(): Readonly<Globals> {
// ESLint does not define `globals` property on `LanguageOptions` if no globals are defined.
// We have no way to distinguish between "no globals defined" and "globals defined as empty object",
// so we behave slightly differently from ESLint here, but it should make no practical difference.
if (globals === null) initGlobals();
debugAssertIsNonNull(globals);
return globals;
},
/**
* Environments defined for the file being linted.
*/
get env(): Readonly<Envs> {
// This is a property which ESLint does not have - it uses `ecmaVersion` instead for preset environments
if (envs === null) initGlobals();
debugAssertIsNonNull(envs);
return envs;
},
};
// In conformance build, replace `LANGUAGE_OPTIONS.ecmaVersion` with a getter which returns value of local var.
// This is to allow changing the ECMAScript version in conformance tests.
// Some of ESLint's rules change behavior based on the version, and ESLint's tests rely on this.
export let ecmaVersion = ECMA_VERSION;
export function setEcmaVersion(version: number): void {
if (!CONFORMANCE) throw new Error("Should be unreachable in release or debug builds");
ecmaVersion = version;
}
if (CONFORMANCE) {
Object.defineProperty(LANGUAGE_OPTIONS, "ecmaVersion", {
get(): number {
return ecmaVersion;
},
});
}
Object.freeze(LANGUAGE_OPTIONS);
/**
* Language options used when parsing a file.
*/
export type LanguageOptions = Readonly<typeof LANGUAGE_OPTIONS>;
// Singleton object for file-specific properties.
//
// Only one file is linted at a time, so we reuse a single object for all files.
// This object is used as the prototype for `Context` objects for each rule.
// It has no state, only getters which return other singletons, or global variables.
//
// IMPORTANT: Getters must not use `this`, to support wrapped context objects.
// https://github.com/oxc-project/oxc/issues/15325
//
// # Deprecated methods
//
// Some methods and getters are deprecated. They are all marked `@deprecated` below.
// These are present in ESLint 9, but are being removed in ESLint 10.
// https://eslint.org/blog/2025/10/whats-coming-in-eslint-10.0.0/#removing-deprecated-rule-context-members
//
// We have decided to keep them, as some existing ESLint plugins use them, and those plugins won't work with Oxlint
// without these methods/getters.
// Our hope is that Oxlint will remain on v1.x for a long time, so we'll be stuck with these deprecated methods
// long after ESlint has removed them.
// We don't think this is a problem, because the implementations are trivial, and no maintenance burden.
//
// However, we still want to discourage using these deprecated methods/getters in rules, because such rules
// will not work in ESLint 10 in compatibility mode.
//
// TODO: Throw an error in `RuleTester` if the rule uses deprecated methods/getters.
// We'll need to offer an option to opt out of these errors, for rules which delegate to another rule whose code
// the author doesn't control.
const FILE_CONTEXT = Object.freeze({
/**
* Absolute path of the file being linted.
*/
get filename(): string {
// Note: If we change this implementation, also change `getFilename` method below
if (filePath === null) throw new Error("Cannot access `context.filename` in `createOnce`");
return filePath;
},
/**
* Get absolute path of the file being linted.
* @returns Absolute path of the file being linted.
* @deprecated Use `context.filename` property instead.
*/
getFilename(): string {
if (filePath === null) throw new Error("Cannot call `context.getFilename` in `createOnce`");
return filePath;
},
/**
* Physical absolute path of the file being linted.
*/
// TODO: Unclear how this differs from `filename`.
get physicalFilename(): string {
// Note: If we change this implementation, also change `getPhysicalFilename` method below
if (filePath === null) {
throw new Error("Cannot access `context.physicalFilename` in `createOnce`");
}
return filePath;
},
/**
* Get physical absolute path of the file being linted.
* @returns Physical absolute path of the file being linted.
* @deprecated Use `context.physicalFilename` property instead.
*/
getPhysicalFilename(): string {
if (filePath === null) {
throw new Error("Cannot call `context.getPhysicalFilename` in `createOnce`");
}
return filePath;
},
/**
* Current working directory.
*/
get cwd(): string {
// Note: If we change this implementation, also change `getCwd` method below
if (filePath === null) throw new Error("Cannot access `context.cwd` in `createOnce`");
debugAssertIsNonNull(cwd, "`cwd` should not be null");
return cwd;
},
/**
* Get current working directory.
* @returns The current working directory.
* @deprecated Use `context.cwd` property instead.
*/
getCwd(): string {
if (filePath === null) throw new Error("Cannot call `context.getCwd` in `createOnce`");
debugAssertIsNonNull(cwd, "`cwd` should not be null");
return cwd;
},
/**
* Source code of the file being linted.
*/
get sourceCode(): SourceCode {
// Note: If we change this implementation, also change `getSourceCode` method below
if (filePath === null) throw new Error("Cannot access `context.sourceCode` in `createOnce`");
return SOURCE_CODE;
},
/**
* Get source code of the file being linted.
* @returns Source code of the file being linted.
* @deprecated Use `context.sourceCode` property instead.
*/
getSourceCode(): SourceCode {
if (filePath === null) throw new Error("Cannot call `context.getSourceCode` in `createOnce`");
return SOURCE_CODE;
},
/**
* Language options used when parsing this file.
*/
get languageOptions(): LanguageOptions {
if (filePath === null) {
throw new Error("Cannot access `context.languageOptions` in `createOnce`");
}
return LANGUAGE_OPTIONS;
},
/**
* Settings for the file being linted.
*/
get settings(): Readonly<Settings> {
if (filePath === null) throw new Error("Cannot access `context.settings` in `createOnce`");
if (settings === null) initSettings();
debugAssertIsNonNull(settings);
return settings;
},
/**
* Create a new object with the current object as the prototype and
* the specified properties as its own properties.
* @param extension - The properties to add to the new object.
* @returns A new object with the current object as the prototype
* and the specified properties as its own properties.
*/
extend(this: FileContext, extension: Record<string | number | symbol, unknown>): FileContext {
// Note: We can allow calling `extend` in `createOnce`, as it involves no file-specific state
return Object.freeze(Object.assign(Object.create(this), extension));
},
/**
* Parser options used to parse the file being linted.
* @deprecated Use `languageOptions.parserOptions` instead.
*/
get parserOptions(): Record<string, unknown> {
if (filePath === null) throw new Error("Cannot access `context.parserOptions` in `createOnce`");
return PARSER_OPTIONS;
},
/**
* The path to the parser used to parse this file.
* @deprecated No longer supported.
*/
get parserPath(): string | undefined {
if (filePath === null) throw new Error("Cannot access `context.parserPath` in `createOnce`");
return undefined;
},
});
/**
* Context object for a file.
* Is the prototype for `Context` objects for each rule.
*/
export type FileContext = typeof FILE_CONTEXT;
/**
* Context object for a rule.
* Passed to `create` and `createOnce` functions.
*/
export interface Context extends FileContext {
/**
* Rule ID, in form `<plugin>/<rule>`.
*/
// Note: This is `null` during `createOnce` call, but we keep the type simple to make it easier for the user.
id: string;
/**
* Rule options for this rule on this file.
*/
// Note: This is `null` during `createOnce` call, but we keep the type simple to make it easier for the user.
options: Readonly<Options>;
/**
* Report an error/warning.
*/
report(this: void, diagnostic: Diagnostic): void;
}
/**
* Create `Context` object for a rule.
* @param ruleDetails - `RuleDetails` object
* @returns `Context` object
*/
export function createContext(ruleDetails: RuleDetails): Context {
// Create `Context` object for rule.
//
// All properties are enumerable, to support a pattern which some ESLint plugins use:
// ```
// function create(context) {
// const wrappedContext = {
// __proto__: Object.getPrototypeOf(context),
// ...context,
// report = (diagnostic) => {
// doSomethingBeforeReporting(diagnostic);
// context.report(diagnostic);
// },
// };
// return baseRule.create(wrappedContext);
// }
// ```
//
// ESLint freezes the `Context` object, but we can't as we need to alter `id` and `options` properties.
// We get as close as we can by making `id` and `options` properties `writable: false`, and preventing user adding
// new properties with `Object.preventExtensions`.
//
// `id` and `options` properties are `configurable` so we can alter them with `Object.defineProperty`.
// In any case, `Context` objects are specific to each rule, so if a rule does mutate the context object,
// that won't affect anyone other than themselves. Options objects are frozen, so user can't mutate them.
//
// IMPORTANT: `report` must not use `this`, to support wrapped context objects
// or e.g. `const { report } = context; report(diagnostic);`.
// https://github.com/oxc-project/oxc/issues/15325
return Object.preventExtensions(
Object.create(FILE_CONTEXT, {
// Rule ID, in form `<plugin>/<rule>`.
// Initially `null` during `createOnce`, set to full rule name after `createOnce` is called.
id: {
value: null!,
enumerable: true,
configurable: true,
},
// Rule options for this rule on this file.
// Initially `null` during `createOnce`, set to options object before linting a file in `lintFileImpl`.
options: {
value: null!,
enumerable: true,
configurable: true,
},
report: {
/**
* Report error.
*
* Normally called with a single `Diagnostic` object.
*
* Can also be called with legacy positional forms:
* - `context.report(node, message, data?, fix?)`
* - `context.report(node, loc, message, data?, fix?)`
* These legacy forms are not included in type def for this method, as they are deprecated,
* but some plugins still use them, so we support them.
*
* @param diagnostic - Diagnostic object
* @throws {TypeError} If `diagnostic` is invalid
*/
value(this: void, diagnostic: Diagnostic, ...extraArgs: unknown[]): void {
// Delegate to `report` implementation shared between all rules, passing rule-specific details (`RuleDetails`)
report(diagnostic, extraArgs, ruleDetails);
},
enumerable: true,
},
}),
);
}