Skip to content
Draft
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
7 changes: 4 additions & 3 deletions config/oxlint/plugins/expensify-rules.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Loads every rule from eslint-plugin-expensify and eslint-plugin-local-rules into oxlint, under the
* Loads the enabled Expensify rules and eslint-plugin-local-rules into oxlint, under the
* `rulesdir` name ESLint uses. Two rules change: no-inline-useOnyx-selector stays quiet in files React
* Compiler memoizes, and prefer-locale-compare-from-context is swapped for a copy that needs no type info.
*/
Expand All @@ -10,15 +10,16 @@ import {fileURLToPath} from 'node:url';

import {create as preferLocaleCompareCreate, meta as preferLocaleCompareMeta} from '../preferLocaleCompareFromContext.mjs';
import {withFullGating} from '../reactCompilerGate.mjs';
import {rules as expensifyRules} from '../vendor/expensify-rules.mjs';

const require = createRequire(import.meta.url);
const pluginDir = path.dirname(fileURLToPath(import.meta.url));

const RULE_DIRS = [path.resolve(pluginDir, '../../../node_modules/eslint-config-expensify/eslint-plugin-expensify'), path.resolve(pluginDir, '../../../eslint-plugin-local-rules')];
const RULE_DIRS = [path.resolve(pluginDir, '../../../eslint-plugin-local-rules')];

const HELPER_FILES = new Set(['CONST.js']);

const rules = {};
const rules = {...expensifyRules};
for (const dir of RULE_DIRS) {
for (const file of fs.readdirSync(dir)) {
if (!file.endsWith('.js') || HELPER_FILES.has(file)) {
Expand Down
89 changes: 15 additions & 74 deletions config/oxlint/plugins/hosted-rules.mjs
Original file line number Diff line number Diff line change
@@ -1,88 +1,29 @@
/**
* Runs rules from ESLint plugins (react, import, jsdoc, storybook, react-hooks, typescript-eslint)
* Runs rules from ESLint plugins (react, import, jsdoc, react-hooks) and syntax-only hosted rules
* inside oxlint, using the plugins' own code. Each one is here because oxlint has no native version or
* its native one reports differently. oxlint reserves most of these plugin names for its own ports,
* so all of them load under `hosted`.
*/
import {createRequire} from 'node:module';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import * as importPlugin from 'eslint-plugin-import';
import * as jsdocPlugin from 'eslint-plugin-jsdoc';
import * as reactPlugin from 'eslint-plugin-react';
import * as reactHooksPlugin from 'eslint-plugin-react-hooks';

import {EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN} from '../../reactCompiler/suppressedRules.mjs';
import {withEslintDirectiveIDs, withEslintDirectiveIDsFor} from '../eslintDirectives.mjs';
import {create as noDeprecatedSelfClosingJsxCreate, meta as noDeprecatedSelfClosingJsxMeta} from '../noDeprecatedSelfClosingJsx.mjs';
import {withFullGating, withMessageGating} from '../reactCompilerGate.mjs';
import {hostedRuleNames} from '../ruleNames.mjs';
import namingConvention from '../vendor/naming-convention.mjs';
import storybookPlugin from '../vendor/storybook.mjs';

const require = createRequire(import.meta.url);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const getRules = (plugin) => plugin.rules ?? plugin.default?.rules ?? {};

/**
* Loads the rules of an installed ESLint plugin, so oxlint runs the same rule code as ESLint.
* @param {string} packageName an ESLint plugin package, or an absolute path to one
* @returns {Record<string, import('eslint').Rule.RuleModule>}
*/
function rulesOf(packageName) {
let mod;
try {
mod = require(path.resolve(repoRoot, 'node_modules', packageName));
} catch {
// A directory require ignores `exports`, so a package with no `main` (typescript-eslint) needs
// the bare specifier.
mod = require(packageName);
}
return mod?.rules ?? mod?.default?.rules ?? {};
}

const react = rulesOf('eslint-plugin-react');
const importPlugin = rulesOf('eslint-plugin-import');
const jsdoc = rulesOf('eslint-plugin-jsdoc');
const storybook = rulesOf('eslint-plugin-storybook');
const typescriptEslint = rulesOf('@typescript-eslint/eslint-plugin');

// eslint-plugin-react-hooks is not a root dependency; ESLint loads the copy nested under
// eslint-config-expensify.
const reactHooks = (() => {
const configEntry = require.resolve('eslint-config-expensify', {paths: [repoRoot]});
return rulesOf(require.resolve('eslint-plugin-react-hooks', {paths: [path.dirname(configEntry)]}));
})();

// naming-convention requires parser services, which oxlint's JS plugins lack.
// getParserServices(context, true) tolerates a missing `program` but fails on missing node maps and
// reads context.languageOptions first, so both are stubbed. This repo's selectors never reach the
// type checker (no `types` selector).
const STUB_PARSER_SERVICES = {
esTreeNodeToTSNodeMap: new Map(),
tsNodeToESTreeNodeMap: new Map(),
};

/**
* Lets typescript-eslint's naming-convention run in oxlint. The rule fails without TypeScript parser
* services, which oxlint's JS plugins do not have, so it gets STUB_PARSER_SERVICES instead.
* @param {import('eslint').Rule.RuleModule} rule
* @returns {import('eslint').Rule.RuleModule}
*/
function withStubbedParserServices(rule) {
return {
...rule,
create(context) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
// A Proxy cannot do this: oxlint defines `parserServices` as a read-only, non-configurable
// property, and a get trap may not return a different value for it.
const stubbedSourceCode = Object.create(sourceCode, {
parserServices: {value: STUB_PARSER_SERVICES},
});
const stubbedContext = Object.create(context, {
sourceCode: {value: stubbedSourceCode},
getSourceCode: {value: () => stubbedSourceCode},
languageOptions: {
value: context.languageOptions ?? {parser: {meta: {name: 'oxlint-js-plugin'}}},
},
});
return rule.create(stubbedContext);
},
};
}
const react = getRules(reactPlugin);
const imports = getRules(importPlugin);
const jsdoc = getRules(jsdocPlugin);
const storybook = storybookPlugin.rules;
const reactHooks = getRules(reactHooksPlugin);

/**
* Loads `names` from an ESLint plugin as hosted rules, and makes `eslint-disable` comments that use the
Expand Down Expand Up @@ -115,10 +56,10 @@ const plugin = {
},
rules: {
...hostRules(react, 'react', plainlyHosted('react')),
...hostRules(importPlugin, 'import', plainlyHosted('import')),
...hostRules(imports, 'import', plainlyHosted('import')),
...hostRules(jsdoc, 'jsdoc', plainlyHosted('jsdoc')),
...hostRules(storybook, 'storybook', plainlyHosted('storybook')),
...hostRules({'naming-convention': withStubbedParserServices(typescriptEslint['naming-convention'])}, '@typescript-eslint', plainlyHosted('@typescript-eslint')),
...hostRules({'naming-convention': namingConvention}, '@typescript-eslint', plainlyHosted('@typescript-eslint')),
'jsx-no-constructed-context-values': withFullGating(withEslintDirectiveIDs(react['jsx-no-constructed-context-values'], 'react/jsx-no-constructed-context-values')),
'no-deprecated-self-closing-jsx': withEslintDirectiveIDs({create: noDeprecatedSelfClosingJsxCreate, meta: noDeprecatedSelfClosingJsxMeta}, '@typescript-eslint/no-deprecated'),
'exhaustive-deps': withMessageGating(withEslintDirectiveIDs(reactHooks['exhaustive-deps'], 'react-hooks/exhaustive-deps'), EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN),
Expand Down
22 changes: 1 addition & 21 deletions config/oxlint/preferLocaleCompareFromContext.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,7 @@
* skips that check. Only strings have `localeCompare`, so the one difference is an object with its own
* `localeCompare`, which this copy also reports.
*/
import {createRequire} from 'node:module';
import path from 'node:path';
import {fileURLToPath} from 'node:url';

const require = createRequire(import.meta.url);
const pluginDir = path.dirname(fileURLToPath(import.meta.url));
const expensifyPlugin = path.resolve(pluginDir, '../../node_modules/eslint-config-expensify/eslint-plugin-expensify');

/**
* Loads a module from eslint-plugin-expensify, so this copy uses the same message as the original rule.
* `require` of these ESM modules returns the namespace, so unwrap `default`.
* @param {string} relative path inside eslint-plugin-expensify
* @returns {any}
*/
function load(relative) {
const module = require(path.join(expensifyPlugin, relative));
return module?.default ?? module;
}

const CONST = load('CONST.js');
const {isInTestFile} = require(path.join(expensifyPlugin, 'utils/index.js'));
import {CONST, isInTestFile} from './vendor/expensify-rules.mjs';

const message = CONST.MESSAGE.PREFER_LOCALE_COMPARE_FROM_CONTEXT;

Expand Down
21 changes: 21 additions & 0 deletions config/oxlint/vendor/@typescript-eslint-eslint-plugin-LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2019 typescript-eslint and other contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
21 changes: 21 additions & 0 deletions config/oxlint/vendor/@typescript-eslint-utils-LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2019 typescript-eslint and other contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
52 changes: 52 additions & 0 deletions config/oxlint/vendor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Syntax-only hosted rules

<!-- cspell:ignore Florian -->

These generated modules retain the rules Oxlint cannot run natively without installing a JavaScript
TypeScript compiler. They are **rule implementations**, not compiler or parser API replacements.
`build.mjs` checks the source versions and rejects generated imports of compiler-dependent packages.
The committed modules run directly; neither installation nor linting rebuilds them.

| npm source | Version | Included code | License |
| --- | --- | --- | --- |
| `eslint-config-expensify` | `4.0.8` | Enabled custom rules, their constants and AST helpers | ISC |
| `@typescript-eslint/eslint-plugin` | `8.58.1` | `naming-convention` and its validators | MIT |
| `@typescript-eslint/utils` | `8.61.0` | Compiler-free AST utilities and rule option defaults | MIT |
| `eslint-plugin-testing-library` | `7.11.0` | The eight enabled rules and their detection helpers | MIT |
| `eslint-plugin-storybook` | `10.6.2` | Storybook rule implementations | MIT |

The MIT notices are beside these modules. `eslint-config-expensify` is authored by
Florian Weber (`florian@expensify.com`) and Expensify and declares the ISC license:

> Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee
> is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
>
> THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE
> INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
> FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
> OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING
> OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

## Deliberate adaptations

- Expensify's disabled `boolean-conditional-rendering` and `prefer-at` rules are not loaded. The existing
`unicorn/prefer-at` rule and `preferLocaleCompareFromContext.mjs` implementation remain configured.
- The naming rule uses Babel's identifier predicates instead of compiler scanner helpers, preserving
the rule's UTF-16 code-unit iteration rather than treating surrogate pairs as Unicode code points.
App's configuration has no type or unused modifiers. Those selectors fail explicitly if introduced;
they are not silently ignored. All other naming checks and diagnostic messages are retained.
- Testing Library and Storybook use the same compiler-free AST predicates, scope utilities, rule
defaults and ESLint utility functions, without importing the compiler-dependent utilities entry point.

## Rebuilding

Use `npm pack <package>@<version>` for the five source packages above and unpack each tarball into a
temporary directory laid out like `node_modules` (including the `@typescript-eslint` scope).
No install or peer dependency override is needed in that source directory. Then, from App:

```sh
node config/oxlint/vendor/build.mjs /absolute/path/to/unpacked-packages
```

Review the generated diff and rerun the existing custom-rule tests and the full lint diagnostic
comparison before updating a source version. Keep the generated files and their license notices together.
Loading
Loading