From 1c23c396234b6ee479d2dc0e531222b5881338a9 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 5 Nov 2026 07:33:46 +0100 Subject: [PATCH 01/43] Upgrade eslint-config-expensify to 4.0.19 without seatbelt --- eslint.config.mjs | 204 +++++++------- lib/Logger.ts | 1 - lib/OnyxUtils.ts | 1 - package-lock.json | 329 ++++++++++++++-------- package.json | 4 +- tests/unit/DevToolsTest.ts | 1 - tests/unit/OnyxSubscriptionManagerTest.ts | 1 - 7 files changed, 321 insertions(+), 220 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 99fb430ab..617dd89d8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,141 +1,135 @@ -import expensify from 'eslint-config-expensify'; -import tsPlugin from '@typescript-eslint/eslint-plugin'; -import tsParser from '@typescript-eslint/parser'; +import browserConfig from 'eslint-config-expensify/browser'; +import jestConfig from 'eslint-config-expensify/jest'; +import reactConfig from 'eslint-config-expensify/react'; +import scriptsConfig from 'eslint-config-expensify/scripts'; +import tsExpensifyConfig from 'eslint-config-expensify/typescript'; import prettierConfig from 'eslint-config-prettier'; +import rulesdir from 'eslint-plugin-rulesdir'; +import {defineConfig, globalIgnores} from 'eslint/config'; +import globals from 'globals'; +import {createRequire} from 'node:module'; +import path from 'node:path'; -export default [ - ...expensify, - prettierConfig, +const require = createRequire(import.meta.url); +const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); +rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); + +export default defineConfig([ + globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**']), + ...browserConfig, + ...reactConfig, + ...tsExpensifyConfig, + ...jestConfig.map((config) => ({ + ...config, + files: ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts'], + })), + ...scriptsConfig, { - ignores: ['dist/**', 'node_modules/**', '.github/**', '*.d.ts', '*.config.js', '*.config.cjs', 'tests/types/**/*.ts'], + plugins: { + rulesdir, + }, }, { - // Overwriting this for now because web-e will conflict with this - files: ['**/*.js', '**/*.jsx'], + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], settings: { 'import/resolver': { - node: { - extensions: ['.js', '.website.js', '.desktop.js', '.native.js', '.ios.js', '.android.js', '.config.js', '.ts', '.tsx'], + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', }, }, }, rules: { - 'react/jsx-filename-extension': [1, {extensions: ['.js']}], - 'rulesdir/no-multiple-onyx-in-file': 'off', - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'never', - jsx: 'never', - ts: 'never', - tsx: 'never', - }, - ], + 'react/prop-types': 'off', + 'react/require-default-props': 'off', }, }, { - files: ['**/*.ts', '**/*.tsx'], - plugins: { - '@typescript-eslint': tsPlugin, - }, + files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], languageOptions: { - parser: tsParser, - parserOptions: { - project: './tsconfig.json', - }, - }, - settings: { - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', - }, - }, + globals: globals.node, }, + }, + // Flat config replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript + // and adds objectLiteralProperty exceptions for Onyx key strings (underscores) and numeric collection IDs. + { + files: ['tests/**/*'], rules: { - ...tsPlugin.configs.recommended.rules, - ...tsPlugin.configs.stylistic.rules, - 'rulesdir/prefer-underscore-method': 'off', - 'react/jsx-props-no-spreading': 'off', - 'react/require-default-props': 'off', - 'react/jsx-filename-extension': ['error', {extensions: ['.tsx', '.jsx']}], - 'import/no-unresolved': 'error', - 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - 'no-use-before-define': 'off', - '@typescript-eslint/no-use-before-define': 'off', - '@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_', caughtErrors: 'none'}], - '@typescript-eslint/consistent-type-imports': ['error', {prefer: 'type-imports'}], - '@typescript-eslint/consistent-type-exports': ['error', {fixMixedExportsWithInlineTypeSpecifier: false}], - '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/array-type': ['error', {default: 'array-simple'}], - '@typescript-eslint/consistent-type-definitions': 'off', - '@typescript-eslint/no-empty-object-type': 'off', - 'rulesdir/no-multiple-onyx-in-file': 'off', - 'valid-jsdoc': 'off', - 'rulesdir/prefer-import-module-contents': 'off', - 'es/no-optional-chaining': 'off', - 'es/no-nullish-coalescing-operators': 'off', - // Disable JSDoc type rules for TypeScript files (TypeScript provides the types) - 'jsdoc/require-param': 'off', - 'jsdoc/require-param-type': 'off', - 'jsdoc/check-param-names': 'off', - 'jsdoc/check-tag-names': 'off', - 'jsdoc/check-types': 'off', - 'no-func-assign': 'off', - 'no-loop-func': 'off', - 'no-redeclare': 'off', - '@typescript-eslint/no-redeclare': 'error', - 'import/extensions': [ + '@typescript-eslint/naming-convention': [ 'error', - 'ignorePackages', { - js: 'never', - jsx: 'never', - ts: 'never', - tsx: 'never', + selector: ['variable', 'property'], + format: null, + filter: { + regex: '^__esModule$', + match: true, + }, + }, + { + selector: ['variable', 'property'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + filter: { + regex: '^private_[a-z][a-zA-Z0-9]*$', + match: false, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '_', + match: true, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '^[0-9]+$', + match: true, + }, + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + { + selector: ['typeLike', 'enumMember'], + format: ['PascalCase'], + }, + { + selector: ['parameter', 'method'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', }, ], - 'rulesdir/prefer-onyx-connect-in-libs': 'off', }, }, { - files: ['tests/**/*.{js,jsx,ts,tsx}', 'jestSetup.js', 'lib/**/__mocks__/**/*.{js,ts}'], + files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], + rules: { + 'import/extensions': 'off', + }, + }, + { + files: ['lib/storage/providers/MemoryOnlyProvider.ts'], languageOptions: { globals: { - jest: 'readonly', - describe: 'readonly', - it: 'readonly', - test: 'readonly', - expect: 'readonly', - beforeEach: 'readonly', - afterEach: 'readonly', - beforeAll: 'readonly', - afterAll: 'readonly', - fail: 'readonly', + ...globals.jest, }, }, - rules: { - '@lwc/lwc/no-async-await': 'off', - 'no-await-in-loop': 'off', - 'no-restricted-syntax': ['error', 'ForInStatement', 'LabeledStatement', 'WithStatement'], - 'import/extensions': 'off', - '@typescript-eslint/no-require-imports': 'off', - 'no-restricted-imports': 'off', - }, }, { - files: ['**/*.native.ts', '**/*.native.tsx'], + files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], rules: { - '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/dot-notation': 'off', }, }, { - files: ['lib/storage/providers/MemoryOnlyProvider.ts'], - languageOptions: { - globals: { - jest: 'readonly', - }, + files: ['tests/perf-test/**/*'], + rules: { + 'rulesdir/prefer-at': 'off', }, }, -]; + prettierConfig, +]); diff --git a/lib/Logger.ts b/lib/Logger.ts index 922b1dd0b..615853e25 100644 --- a/lib/Logger.ts +++ b/lib/Logger.ts @@ -7,7 +7,6 @@ type LogData = { }; type LoggerCallback = (data: LogData) => void; -// eslint-disable-next-line @typescript-eslint/no-empty-function let logger: LoggerCallback = () => {}; /** diff --git a/lib/OnyxUtils.ts b/lib/OnyxUtils.ts index ed557f585..806eeaa46 100644 --- a/lib/OnyxUtils.ts +++ b/lib/OnyxUtils.ts @@ -91,7 +91,6 @@ let deferredInitTask = createDeferredTask(); * to be distinct from real values, not unique per subscription, so one module-level instance * is reused by every connection. */ -// eslint-disable-next-line rulesdir/no-negated-variables const NOT_DELIVERED = Symbol('NOT_DELIVERED'); // Collection member IDs that Onyx should silently ignore across all operations — reads, writes, cache, and subscriber diff --git a/package-lock.json b/package-lock.json index 44159468c..cadc07efd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,13 +43,15 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "2.0.108", + "eslint-config-expensify": "4.0.19", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", + "eslint-plugin-rulesdir": "^0.2.2", "fake-indexeddb": "^6.2.5", + "globals": "^15.15.0", "idb-keyval": "^6.2.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", @@ -2534,9 +2536,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -3316,6 +3318,19 @@ "eslint": "^9" } }, + "node_modules/@lwc/eslint-plugin-lwc/node_modules/globals": { + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", + "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4549,20 +4564,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", - "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz", + "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/type-utils": "8.51.0", - "@typescript-eslint/utils": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/type-utils": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.2.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4572,9 +4587,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.51.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.72.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -4588,17 +4603,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", - "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz", + "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4608,20 +4623,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", - "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz", + "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.51.0", - "@typescript-eslint/types": "^8.51.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.72.1", + "@typescript-eslint/types": "^8.72.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4631,18 +4646,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", - "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz", + "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0" + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4653,9 +4668,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", - "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz", + "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==", "dev": true, "license": "MIT", "engines": { @@ -4666,21 +4681,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", - "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz", + "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0", - "@typescript-eslint/utils": "8.51.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.2.0" + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4690,14 +4705,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", - "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz", + "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==", "dev": true, "license": "MIT", "engines": { @@ -4709,21 +4724,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", - "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz", + "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.51.0", - "@typescript-eslint/tsconfig-utils": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/visitor-keys": "8.51.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.72.1", + "@typescript-eslint/tsconfig-utils": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/visitor-keys": "8.72.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.2.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4733,13 +4748,52 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -4750,16 +4804,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", - "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz", + "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.51.0", - "@typescript-eslint/types": "8.51.0", - "@typescript-eslint/typescript-estree": "8.51.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.72.1", + "@typescript-eslint/types": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4769,19 +4823,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", - "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz", + "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.51.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.72.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4792,13 +4846,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7622,29 +7676,30 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.108", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.108.tgz", - "integrity": "sha512-J+tW0s5rli+ewOdY3fyf8Hk3G8WJho4YPNlTeSyeS+zD4JsQcqpOFF9wKzQFr8Tl/2E9f/4gxcYo2xiHolONLA==", + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-4.0.19.tgz", + "integrity": "sha512-bO1Mm+wOdr6EjHpaJPbaEXkr7TyAkQttlQa5myXysCM8mQRivzPAIPcDIAtWufIoF/AKswlS4zpSrfw0a1UwBw==", "dev": true, "license": "ISC", "dependencies": { "@babel/eslint-parser": "^7.28.4", - "@lwc/eslint-plugin-lwc": "^3.2.0", "@typescript-eslint/parser": "^8.44.1", "@typescript-eslint/utils": "^8.44.1", "confusing-browser-globals": "^1.0.11", "eslint": "^9.36.0", "eslint-plugin-es": "^4.1.0", "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jest": "^29.0.1", "eslint-plugin-jsdoc": "^60.2.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-rulesdir": "^0.2.2", "eslint-plugin-unicorn": "^61.0.2", "globals": "^15.14.0", - "lodash": "^4.17.21", - "underscore": "^1.13.6" + "lodash": "^4.18.1", + "typescript-eslint": "^8.61.0", + "underscore": "^1.13.8" } }, "node_modules/eslint-config-expensify/node_modules/ci-info": { @@ -7664,9 +7719,9 @@ } }, "node_modules/eslint-config-expensify/node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -7680,7 +7735,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-config-expensify/node_modules/eslint-plugin-unicorn": { @@ -8087,6 +8142,36 @@ "node": "*" } }, + "node_modules/eslint-plugin-jest": { + "version": "29.17.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.17.0.tgz", + "integrity": "sha512-6jtpU6MhYKbagkES02TupWAUUooJpZtKg94Ax0b8HhUorv3InSTBU8RRtlSBe0LocOcShXOpyec3PHzPDkJCSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/eslint-plugin-jsdoc": { "version": "60.8.3", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-60.8.3.tgz", @@ -9411,9 +9496,9 @@ } }, "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, "license": "MIT", "engines": { @@ -11244,12 +11329,12 @@ "dev": true, "funding": [ { - "url": "https://github.com/sponsors/puzrin", - "type": "github" + "type": "github", + "url": "https://github.com/sponsors/puzrin" }, { - "url": "https://github.com/sponsors/nodeca", - "type": "github" + "type": "github", + "url": "https://github.com/sponsors/nodeca" } ], "license": "MIT", @@ -15800,9 +15885,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz", - "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -16360,6 +16445,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.72.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz", + "integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.72.1", + "@typescript-eslint/parser": "8.72.1", + "@typescript-eslint/typescript-estree": "8.72.1", + "@typescript-eslint/utils": "8.72.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/typical": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/typical/-/typical-2.6.1.tgz", diff --git a/package.json b/package.json index 95d7a0687..26966792c 100644 --- a/package.json +++ b/package.json @@ -77,13 +77,15 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "2.0.108", + "eslint-config-expensify": "4.0.19", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", + "eslint-plugin-rulesdir": "^0.2.2", "fake-indexeddb": "^6.2.5", + "globals": "^15.15.0", "idb-keyval": "^6.2.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", diff --git a/tests/unit/DevToolsTest.ts b/tests/unit/DevToolsTest.ts index 60664c5b3..6ab1232e8 100644 --- a/tests/unit/DevToolsTest.ts +++ b/tests/unit/DevToolsTest.ts @@ -1,4 +1,3 @@ -/* eslint-disable dot-notation */ import Onyx from '../../lib'; import {getDevToolsInstance} from '../../lib/DevTools'; import type {DevtoolsConnection, RealDevTools as RealDevToolsType} from '../../lib/DevTools'; diff --git a/tests/unit/OnyxSubscriptionManagerTest.ts b/tests/unit/OnyxSubscriptionManagerTest.ts index aa8488237..0c73e9f72 100644 --- a/tests/unit/OnyxSubscriptionManagerTest.ts +++ b/tests/unit/OnyxSubscriptionManagerTest.ts @@ -11,7 +11,6 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; // We need access to some internal properties of `onyxSubscriptionManager` during the tests but they are private, // so this workaround allows us to have access to them. The maps are created once in the constructor // and only ever `.clear()`ed (never reassigned), so capturing the references here stays valid. -// eslint-disable-next-line dot-notation const keyListeners = onyxSubscriptionManager['keyListeners']; const ONYXKEYS = { From 94238a5f01bf92d7dbcd64181c9950c7e0bd1df5 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 5 Nov 2026 07:35:40 +0100 Subject: [PATCH 02/43] Add ESLint 4.x lint snapshot under lint-parity --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 617dd89d8..d830a086d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,7 +15,7 @@ const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-exp rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); export default defineConfig([ - globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**']), + globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**', 'lint-parity/**']), ...browserConfig, ...reactConfig, ...tsExpensifyConfig, From ee555b74477308e390f33b67f433c27ca206d71c Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 5 Nov 2026 07:45:38 +0100 Subject: [PATCH 03/43] Switch tsconfig to bundler resolution for tsgolint, keep CommonJS build --- lib/types/modules/react-native-nitro-sqlite.d.ts | 7 +------ tsconfig.build.json | 4 ++++ tsconfig.json | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/types/modules/react-native-nitro-sqlite.d.ts b/lib/types/modules/react-native-nitro-sqlite.d.ts index 27cb3ffc2..154b85ce9 100644 --- a/lib/types/modules/react-native-nitro-sqlite.d.ts +++ b/lib/types/modules/react-native-nitro-sqlite.d.ts @@ -1,9 +1,4 @@ -// Expose the native-free subpaths to TypeScript's legacy Node module resolver. +// Expose the native-free errors subpath to TypeScript's legacy Node module resolver (used by tsconfig.build.json). declare module 'react-native-nitro-sqlite/errors' { export {NitroSQLiteError, NitroSQLiteExceptionType} from 'react-native-nitro-sqlite'; } - -declare module 'react-native-nitro-sqlite/mock' { - // eslint-disable-next-line import/no-unresolved -- Legacy Node type resolution uses this declaration file outside package exports. - export * from 'react-native-nitro-sqlite/lib/typescript/commonjs/mock'; -} diff --git a/tsconfig.build.json b/tsconfig.build.json index 78e6b52a3..79205ce73 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,5 +1,9 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node" + }, "include": ["./lib"] } diff --git a/tsconfig.json b/tsconfig.json index d920b8700..f735b15f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "target": "es2015", - "module": "commonjs", + "module": "esnext", "types": ["react-native", "react", "jest", "node"], "lib": ["esnext", "dom"], "allowJs": true, @@ -10,7 +10,7 @@ "jsx": "react", "isolatedModules": true, "strict": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "esModuleInterop": true, "skipLibCheck": true, From 2549d8fd243a78ce5313c2b7f691d20e4b66b0d5 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 5 Nov 2026 07:45:38 +0100 Subject: [PATCH 04/43] Add Oxlint 1.90.0 type-aware config with JS plugins for ESLint-only rules --- config/oxlint/eslintDirectives.mjs | 176 +++ config/oxlint/plugins/core-rules.mjs | 38 + config/oxlint/plugins/expensify-rules.mjs | 48 + config/oxlint/plugins/hosted-rules.mjs | 95 ++ config/oxlint/ruleNames.mjs | 111 ++ eslint.config.mjs | 2 +- oxlint.config.mts | 1410 +++++++++++++++++++++ package-lock.json | 500 ++++++++ package.json | 2 + 9 files changed, 2381 insertions(+), 1 deletion(-) create mode 100644 config/oxlint/eslintDirectives.mjs create mode 100644 config/oxlint/plugins/core-rules.mjs create mode 100644 config/oxlint/plugins/expensify-rules.mjs create mode 100644 config/oxlint/plugins/hosted-rules.mjs create mode 100644 config/oxlint/ruleNames.mjs create mode 100644 oxlint.config.mts diff --git a/config/oxlint/eslintDirectives.mjs b/config/oxlint/eslintDirectives.mjs new file mode 100644 index 000000000..e0f5de6a5 --- /dev/null +++ b/config/oxlint/eslintDirectives.mjs @@ -0,0 +1,176 @@ +/** + * oxlint runs some ESLint rules as JS plugins under a new name. For example, + * `@typescript-eslint/naming-convention` becomes `hosted/naming-convention`. oxlint doesn't know the + * two are the same rule, so an existing `eslint-disable-next-line @typescript-eslint/naming-convention` + * comment wouldn't hide its report. The wrappers here read those comments and drop what ESLint would hide. + */ + +const JUSTIFICATION = /\s-{2,}\s/; +const KINDS = ['eslint-disable-next-line', 'eslint-disable-line', 'eslint-disable', 'eslint-enable']; +const ALL_RULES = ' all'; + +const cache = new Map(); + +/** + * Turns a comment like `eslint-disable-next-line rule-a, rule-b -- reason` into its kind and the rules + * it names. A directive that names no rule applies to every rule. + * @param {{type: 'Line' | 'Block', value: string, loc?: {start: {line: number, column: number}, end: {line: number, column: number}}}} comment + * @returns {{kind: string, ruleIds: string[]} | null} null when the comment is not a directive + */ +function parseComment(comment) { + const text = String(comment.value ?? '').trim(); + const kind = KINDS.find((candidate) => text === candidate || (text.startsWith(candidate) && /^\s/.test(text.slice(candidate.length)))); + if (!kind) { + return null; + } + const body = text.slice(kind.length).split(JUSTIFICATION)[0]; + const ruleIds = body + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + return {kind, ruleIds: ruleIds.length ? ruleIds : [ALL_RULES]}; +} + +/** + * Collects a file's disable comments in a form that is quick to check for each report. A + * `-line`/`-next-line` comment becomes the line it covers. `eslint-disable`/`eslint-enable` comments + * stay in source order, because the last one before a report decides. + * @param {Array[0]>} comments + * @returns {{lines: Map>, blocks: Array<{line: number, column: number, ruleId: string, disable: boolean}>}} + */ +function parseDirectives(comments) { + const lines = new Map(); + const blocks = []; + + for (const comment of comments) { + const parsed = parseComment(comment); + const start = comment.loc?.start?.line; + const end = comment.loc?.end?.line; + if (!parsed || start == null || end == null) { + continue; + } + // ESLint reads `eslint-disable` and `eslint-enable` only from block comments. + const isBlockKind = parsed.kind === 'eslint-disable' || parsed.kind === 'eslint-enable'; + if (isBlockKind && comment.type !== 'Block') { + continue; + } + for (const ruleId of parsed.ruleIds) { + if (parsed.kind === 'eslint-disable-next-line' || parsed.kind === 'eslint-disable-line') { + // ESLint ignores a multi-line `-line` directive but applies a multi-line `-next-line` + // to the line after the comment ends. + if (parsed.kind === 'eslint-disable-line' && start !== end) { + continue; + } + if (!lines.has(ruleId)) { + lines.set(ruleId, new Set()); + } + lines.get(ruleId).add(parsed.kind === 'eslint-disable-line' ? start : end + 1); + } else { + blocks.push({line: start, column: comment.loc.start.column ?? 0, ruleId, disable: parsed.kind === 'eslint-disable'}); + } + } + } + + return {lines, blocks}; +} + +/** + * Every wrapped rule checks the same file's comments, so they are parsed once per file. + * @param {string | undefined} filename + * @param {Array[0]>} comments + * @returns {ReturnType} + */ +function directivesFor(filename, comments) { + const key = filename ?? ''; + if (!cache.has(key)) { + cache.set(key, parseDirectives(comments)); + } + return cache.get(key); +} + +/** + * Tells whether ESLint would hide a report of `eslintRuleId` at `position`. As in ESLint, the last + * `eslint-disable`/`eslint-enable` before the report, by line and then column, that names the rule + * (or no rule) wins. + * @param {ReturnType} directives + * @param {string} eslintRuleId + * @param {{line: number, column: number} | null} position + * @returns {boolean} + */ +function isSuppressed(directives, eslintRuleId, position) { + if (position == null) { + return false; + } + const {line, column} = position; + if (directives.lines.get(eslintRuleId)?.has(line) || directives.lines.get(ALL_RULES)?.has(line)) { + return true; + } + let disabled = false; + for (const block of directives.blocks) { + const isAfterReport = block.line > line || (block.line === line && block.column > column); + if (isAfterReport || (block.ruleId !== ALL_RULES && block.ruleId !== eslintRuleId)) { + continue; + } + disabled = block.disable; + } + return disabled; +} + +/** + * Finds the line and column a report points at, to check it against the disable comments. Rules pass + * either a descriptor with `loc` or `node`, or a node in the legacy `report(node, message)` form. + * @param {unknown[]} args + * @returns {{line: number, column: number} | null} + */ +function reportPosition(args) { + const first = args[0]; + const candidates = [first?.loc?.start, first?.loc, first?.node?.loc?.start, first?.node?.loc]; + const point = candidates.find((candidate) => typeof candidate?.line === 'number'); + return point ? {line: point.line, column: typeof point.column === 'number' ? point.column : 0} : null; +} + +/** + * Makes an oxlint JS plugin rule respect `eslint-disable` comments written for the ESLint rule it + * copies. Without it, `// eslint-disable-next-line react-hooks/exhaustive-deps` would not hide the + * oxlint copy's report, because oxlint calls that copy `hosted/exhaustive-deps`. + * @param {import('eslint').Rule.RuleModule} rule + * @param {string} eslintRuleId the ID ESLint reports this rule under, like `react-hooks/exhaustive-deps` + * @returns {import('eslint').Rule.RuleModule} + */ +function withEslintDirectiveIDs(rule, eslintRuleId) { + return { + ...rule, + create(context) { + const filteredContext = Object.create(context, { + report: { + // Keeps every argument: ESLint also accepts the legacy `report(node, message)` form. + value(...args) { + const position = reportPosition(args); + if (position != null) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + const filename = context.filename ?? context.getFilename(); + if (isSuppressed(directivesFor(filename, sourceCode.getAllComments()), eslintRuleId, position)) { + return; + } + } + return context.report(...args); + }, + }, + }); + return rule.create(filteredContext); + }, + }; +} + +/** + * withEslintDirectiveIDs for a whole plugin at once. `toEslintId` gives the ESLint name of each rule, + * like `naming-convention` to `@typescript-eslint/naming-convention`. + * @param {Record} rules + * @param {(name: string) => string} toEslintId maps an oxlint rule name to its ESLint ID + * @returns {Record} + */ +function withEslintDirectiveIDsFor(rules, toEslintId) { + return Object.fromEntries(Object.entries(rules).map(([name, rule]) => [name, withEslintDirectiveIDs(rule, toEslintId(name))])); +} + +export {withEslintDirectiveIDs, withEslintDirectiveIDsFor}; diff --git a/config/oxlint/plugins/core-rules.mjs b/config/oxlint/plugins/core-rules.mjs new file mode 100644 index 000000000..48c21372e --- /dev/null +++ b/config/oxlint/plugins/core-rules.mjs @@ -0,0 +1,38 @@ +/** + * ESLint core rules that Oxlint has no native (non-nursery) version of. They run inside Oxlint as a JS + * plugin under the `core/` prefix, using ESLint's own rule code, and respect `eslint-disable` comments + * written with the ESLint rule id. Which files each one runs on is set in oxlint.config.mts, copying ESLint. + * Adapted from Expensify/App config/oxlint/plugins/core-rules.mjs. + * + * no-invalid-this is left out, as in App: it calls sourceCode.getJSDocComment, which Oxlint's plugin + * bridge does not support. + */ +import {builtinRules} from 'eslint/use-at-your-own-risk'; + +import {withEslintDirectiveIDsFor} from '../eslintDirectives.mjs'; + +const NAMES = [ + 'no-restricted-syntax', + 'lines-between-class-members', + 'no-octal', + 'no-octal-escape', + 'no-undef-init', + 'strict', + 'no-dupe-args', + 'no-return-await', + 'no-new-symbol', + 'no-new-object', + 'no-unreachable-loop', + 'no-restricted-exports', + 'no-undef', +]; + +const plugin = { + meta: { + name: 'core', + version: '0.0.1', + }, + rules: withEslintDirectiveIDsFor(Object.fromEntries(NAMES.map((name) => [name, builtinRules.get(name)])), (name) => name), +}; + +export default plugin; diff --git a/config/oxlint/plugins/expensify-rules.mjs b/config/oxlint/plugins/expensify-rules.mjs new file mode 100644 index 000000000..01b5c702b --- /dev/null +++ b/config/oxlint/plugins/expensify-rules.mjs @@ -0,0 +1,48 @@ +/** + * Loads every rule from eslint-config-expensify's eslint-plugin-expensify into Oxlint under the `rulesdir` + * name ESLint uses, so `rulesdir/` ids and `eslint-disable` comments stay the same. + * Adapted from Expensify/App config/oxlint/plugins/expensify-rules.mjs. + * + * Rules that call `ESLintUtils.getParserServices` (type-aware rules such as `prefer-at` and + * `boolean-conditional-rendering`) cannot run here: Oxlint JS plugins get no TypeScript program. + * They are listed in TYPE_AWARE_RULES and left out, so enabling one fails loudly at config load + * instead of silently reporting nothing. `oxlint.config.mts` uses native stand-ins for them. + */ +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; + +const require = createRequire(import.meta.url); +const RULES_DIR = path.join(path.dirname(require.resolve('eslint-config-expensify')), 'eslint-plugin-expensify'); + +const HELPER_FILES = new Set(['CONST.js']); + +/** Rules whose source needs TypeScript parser services. Kept in sync by hand with eslint-plugin-expensify. */ +const TYPE_AWARE_RULES = new Set(); + +const rules = {}; +for (const file of fs.readdirSync(RULES_DIR)) { + if (!file.endsWith('.js') || HELPER_FILES.has(file)) { + continue; + } + const name = file.replace(/\.js$/, ''); + const source = fs.readFileSync(path.join(RULES_DIR, file), 'utf8'); + if (source.includes('getParserServices')) { + TYPE_AWARE_RULES.add(name); + continue; + } + const mod = await import(pathToFileURL(path.join(RULES_DIR, file)).href); + rules[name] = mod.default ?? {meta: mod.meta, create: mod.create}; +} + +const plugin = { + meta: { + name: 'rulesdir', + version: '0.0.1', + }, + rules, +}; + +export {TYPE_AWARE_RULES}; +export default plugin; diff --git a/config/oxlint/plugins/hosted-rules.mjs b/config/oxlint/plugins/hosted-rules.mjs new file mode 100644 index 000000000..398acb522 --- /dev/null +++ b/config/oxlint/plugins/hosted-rules.mjs @@ -0,0 +1,95 @@ +/** + * Runs rules from ESLint plugins (typescript-eslint, import, jsdoc, react) inside Oxlint, using the plugins' + * own code, for rules Oxlint has no native version of. Oxlint reserves those plugin names for its own + * ports, so all of them load under `hosted`; config/oxlint/ruleNames.mjs maps them back to ESLint ids. + * Adapted from Expensify/App config/oxlint/plugins/hosted-rules.mjs (React Compiler gating removed). + */ +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {withEslintDirectiveIDsFor} from '../eslintDirectives.mjs'; +import {hostedRuleNames} from '../ruleNames.mjs'; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +/** + * Loads the rules of an installed ESLint plugin, so Oxlint runs the same rule code as ESLint. + * @param {string} packageName + * @returns {Record} + */ +function rulesOf(packageName) { + const mod = require(require.resolve(packageName, {paths: [repoRoot]})); + 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 typescriptEslint = rulesOf('@typescript-eslint/eslint-plugin'); + +// naming-convention asks for parser services, which Oxlint 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. Onyx's naming-convention options use no `types` selector, so the type checker is never hit. +const STUB_PARSER_SERVICES = { + esTreeNodeToTSNodeMap: new Map(), + tsNodeToESTreeNodeMap: new Map(), +}; + +/** + * @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 read-only and non-configurable. + 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); + }, + }; +} + +/** + * Picks `names` from an ESLint plugin and makes `eslint-disable` comments that use the ESLint id + * (`/`) apply to them. + * @param {Record} rules + * @param {string} eslintPrefix + * @returns {Record} + */ +function hostRules(rules, eslintPrefix) { + const names = hostedRuleNames(eslintPrefix); + for (const name of names) { + if (!rules[name]) { + throw new Error(`${eslintPrefix}/${name} not found in the installed ESLint plugin`); + } + } + return withEslintDirectiveIDsFor(Object.fromEntries(names.map((name) => [name, rules[name]])), (name) => `${eslintPrefix}/${name}`); +} + +const plugin = { + meta: { + name: 'hosted', + version: '0.0.1', + }, + rules: { + ...hostRules(react, 'react'), + ...hostRules(importPlugin, 'import'), + ...hostRules(jsdoc, 'jsdoc'), + ...hostRules({'naming-convention': withStubbedParserServices(typescriptEslint['naming-convention'])}, '@typescript-eslint'), + }, +}; + +export default plugin; diff --git a/config/oxlint/ruleNames.mjs b/config/oxlint/ruleNames.mjs new file mode 100644 index 000000000..c97eeaf7c --- /dev/null +++ b/config/oxlint/ruleNames.mjs @@ -0,0 +1,111 @@ +/** + * Oxlint and ESLint name some rules differently. This maps between the two, so Oxlint findings can be + * reported, compared and gated under ESLint's rule ids. Adapted from Expensify/App config/oxlint/ruleNames.mjs. + * Plain `.mjs` because Oxlint JS plugins cannot load TypeScript. + */ +const OXLINT_RULE_RENAMES = { + 'no-object-constructor': 'no-new-object', + 'no-new-native-nonconstructor': 'no-new-symbol', +}; + +// Oxlint reserves its native plugin prefixes (`react`, `import`, `jsdoc`, `@typescript-eslint`), so rules +// hosted from those ESLint plugins load under `hosted`. This maps each one back to its ESLint plugin. +const HOSTED_RULE_ORIGIN = { + 'naming-convention': '@typescript-eslint', + 'no-extraneous-dependencies': 'import', + 'no-useless-path-segments': 'import', + 'no-import-module-exports': 'import', + 'no-relative-packages': 'import', + export: 'import', + named: 'import', + order: 'import', + 'no-types': 'jsdoc', + 'check-param-names': 'jsdoc', + 'check-types': 'jsdoc', + 'jsx-no-bind': 'react', + 'no-typos': 'react', + 'no-arrow-function-lifecycle': 'react', + 'no-invalid-html-attribute': 'react', + 'require-render-return': 'react', + 'forbid-prop-types': 'react', + 'forbid-foreign-prop-types': 'react', + 'default-props-match-prop-types': 'react', + 'no-deprecated': 'react', + 'prefer-stateless-function': 'react', + 'sort-comp': 'react', + 'no-unused-state': 'react', + 'no-access-state-in-setstate': 'react', + 'static-property-placement': 'react', + 'prefer-exact-props': 'react', + 'no-unused-class-component-methods': 'react', +}; + +// Oxlint native plugin names that differ from the ESLint plugin prefix. +const NATIVE_PLUGIN_PREFIX = { + typescript: '@typescript-eslint', + jsx_a11y: 'jsx-a11y', + react_perf: 'react-perf', +}; + +// ESLint runs the @typescript-eslint extension of these rules on TS files and the core rule on JS files. +// Oxlint has one native rule (reported as `eslint()`) that understands TypeScript, so the ESLint id +// depends on the file being linted. +const TS_EXTENSION_RULES = new Set([ + 'default-param-last', + 'max-params', + 'no-array-constructor', + 'no-dupe-class-members', + 'no-empty-function', + 'no-loop-func', + 'no-loss-of-precision', + 'no-redeclare', + 'no-shadow', + 'no-unused-expressions', + 'no-unused-vars', + 'no-use-before-define', + 'no-useless-constructor', +]); +const TS_FILE = /\.(ts|tsx|mts|cts)$/; + +const OXLINT_DIAGNOSTIC_CODE = /^([\w@/.-]+)\((.+)\)$/; + +/** + * Lists the hosted rules copied from one ESLint plugin. + * @param {string} eslintPrefix an ESLint plugin name, like `react` + * @returns {string[]} + */ +function hostedRuleNames(eslintPrefix) { + return Object.keys(HOSTED_RULE_ORIGIN).filter((name) => HOSTED_RULE_ORIGIN[name] === eslintPrefix); +} + +/** + * Converts an Oxlint diagnostic code, like `typescript(no-explicit-any)`, to ESLint's rule id, like + * `@typescript-eslint/no-explicit-any`. A code that does not look like `plugin(rule)` comes back unchanged. + * @param {string} code + * @param {string} [filename] the linted file; needed to tell `no-unused-vars` from `@typescript-eslint/no-unused-vars` + * @returns {string} + */ +function oxlintCodeToESLintRuleID(code, filename = '') { + const match = OXLINT_DIAGNOSTIC_CODE.exec(code); + if (!match) { + return code; + } + const [, rawPlugin, rule] = match; + const plugin = rawPlugin.replace(/^eslint-plugin-/, ''); + if (plugin === 'eslint' && TS_EXTENSION_RULES.has(rule) && TS_FILE.test(filename)) { + return `@typescript-eslint/${rule}`; + } + if (plugin === 'eslint' || plugin === 'core') { + return OXLINT_RULE_RENAMES[rule] ?? rule; + } + if (plugin === 'hosted') { + const origin = HOSTED_RULE_ORIGIN[rule]; + if (!origin) { + throw new Error(`No HOSTED_RULE_ORIGIN entry for hosted rule "${rule}". Add it to config/oxlint/ruleNames.mjs.`); + } + return `${origin}/${rule}`; + } + return `${NATIVE_PLUGIN_PREFIX[plugin] ?? plugin}/${rule}`; +} + +export {HOSTED_RULE_ORIGIN, OXLINT_RULE_RENAMES, TS_EXTENSION_RULES, hostedRuleNames, oxlintCodeToESLintRuleID}; diff --git a/eslint.config.mjs b/eslint.config.mjs index d830a086d..a4985352d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,7 +15,7 @@ const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-exp rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); export default defineConfig([ - globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**', 'lint-parity/**']), + globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**', 'lint-parity/**', 'oxlint.config.mts', 'config/oxlint/**']), ...browserConfig, ...reactConfig, ...tsExpensifyConfig, diff --git a/oxlint.config.mts b/oxlint.config.mts new file mode 100644 index 000000000..e443c8475 --- /dev/null +++ b/oxlint.config.mts @@ -0,0 +1,1410 @@ +/** + * Oxlint config for react-native-onyx, kept in parity with eslint.config.mjs (eslint-config-expensify 4.x). + * + * Starting point: `npx @oxlint/migrate@1.90.0 eslint.config.mjs --type-aware` (raw output kept in + * lint-parity/oxlintrc.migrate-raw.json), turned into this file by lint-parity/scripts/generateOxlintConfig.mjs + * and then edited by hand. See lint-parity/README.md for parity results and known gaps. + * + * Rules Oxlint has no native version of run through JS plugins (see config/oxlint/plugins): + * - core/*: ESLint core rules, with ESLint's own code. + * - hosted/*: rules from ESLint plugins (typescript-eslint naming-convention, import, jsdoc, react). + * - rulesdir/*: eslint-plugin-expensify rules that need no type information. + * + * Type-aware eslint-plugin-expensify rules cannot run in a JS plugin (Oxlint gives JS plugins no TypeScript program): + * - rulesdir/prefer-at: unicorn/prefer-at stands in for it and covers fewer cases. + * - rulesdir/boolean-conditional-rendering: dropped, no Oxlint equivalent. + */ +import {defineConfig} from 'oxlint'; + +export default defineConfig({ + plugins: ['typescript', 'import', 'jsx-a11y', 'unicorn', 'react'], + jsPlugins: [ + { + name: 'rulesdir', + specifier: './config/oxlint/plugins/expensify-rules.mjs', + }, + { + name: 'core', + specifier: './config/oxlint/plugins/core-rules.mjs', + }, + { + name: 'hosted', + specifier: './config/oxlint/plugins/hosted-rules.mjs', + }, + ], + categories: { + correctness: 'off', + }, + options: { + typeAware: true, + }, + env: { + builtin: true, + es2026: true, + browser: true, + }, + settings: { + propWrapperFunctions: ['forbidExtraProps', 'exact', 'Object.freeze'], + react: { + version: '18.2.0', + }, + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.json'], + }, + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', + }, + }, + 'import/extensions': ['.js', '.mjs', '.jsx', '.ts', '.tsx', '.cts', '.mts'], + 'import/core-modules': [], + 'import/ignore': ['node_modules', '\\.(coffee|scss|css|less|hbs|svg|json)$'], + 'import/external-module-folders': ['node_modules', 'node_modules/@types'], + }, + ignorePatterns: [ + 'dist/**', + 'node_modules/**', + '**/*.d.ts', + '**/*.config.js', + '**/*.config.cjs', + 'tests/types/**/*.ts', + 'cpp/**', + 'bench-results/**', + '.github/**', + 'lint-parity/**', + 'oxlint.config.mts', + 'config/oxlint/**', + ], + rules: { + 'array-callback-return': [ + 'error', + { + allowImplicit: true, + }, + ], + 'block-scoped-var': 'error', + 'default-case': [ + 'error', + { + commentPattern: '^no default$', + }, + ], + 'default-case-last': 'error', + 'default-param-last': 'error', + eqeqeq: [ + 'error', + 'always', + { + null: 'ignore', + }, + ], + 'grouped-accessor-pairs': 'error', + 'guard-for-in': 'error', + 'max-classes-per-file': ['error', 1], + 'no-caller': 'error', + 'no-case-declarations': 'error', + 'no-constructor-return': 'error', + 'no-else-return': [ + 'error', + { + allowElseIf: false, + }, + ], + 'no-empty-function': [ + 'error', + { + allow: ['arrowFunctions', 'functions', 'methods'], + }, + ], + 'no-empty-pattern': 'error', + 'no-eval': 'error', + 'no-extend-native': 'error', + 'no-extra-bind': 'error', + 'no-extra-label': 'error', + 'no-fallthrough': 'error', + 'no-global-assign': [ + 'error', + { + exceptions: [], + }, + ], + 'no-implied-eval': 'error', + 'no-iterator': 'error', + 'no-labels': [ + 'error', + { + allowLoop: false, + allowSwitch: false, + }, + ], + 'no-lone-blocks': 'error', + 'no-loop-func': 'error', + 'no-multi-str': 'error', + 'no-new': 'error', + 'no-new-func': 'error', + 'no-new-wrappers': 'error', + 'no-nonoctal-decimal-escape': 'error', + 'no-param-reassign': [ + 'error', + { + props: true, + ignorePropertyModificationsFor: ['acc', 'accumulator', 'e', 'ctx', 'context', 'req', 'request', 'res', 'response', '$scope', 'staticContext'], + }, + ], + 'no-proto': 'error', + 'no-restricted-properties': [ + 'error', + { + object: 'arguments', + property: 'callee', + message: 'arguments.callee is deprecated', + }, + { + object: 'global', + property: 'isFinite', + message: 'Please use Number.isFinite instead', + }, + { + object: 'self', + property: 'isFinite', + message: 'Please use Number.isFinite instead', + }, + { + object: 'window', + property: 'isFinite', + message: 'Please use Number.isFinite instead', + }, + { + object: 'global', + property: 'isNaN', + message: 'Please use Number.isNaN instead', + }, + { + object: 'self', + property: 'isNaN', + message: 'Please use Number.isNaN instead', + }, + { + object: 'window', + property: 'isNaN', + message: 'Please use Number.isNaN instead', + }, + { + property: '__defineGetter__', + message: 'Please use Object.defineProperty instead.', + }, + { + property: '__defineSetter__', + message: 'Please use Object.defineProperty instead.', + }, + { + object: 'Math', + property: 'pow', + message: 'Use the exponentiation operator (**) instead.', + }, + ], + 'no-script-url': 'error', + 'no-self-assign': [ + 'error', + { + props: true, + }, + ], + 'no-self-compare': 'error', + 'no-sequences': 'error', + 'no-throw-literal': 'error', + 'no-unused-expressions': [ + 'error', + { + allowShortCircuit: false, + allowTernary: false, + allowTaggedTemplates: false, + }, + ], + 'no-unused-labels': 'error', + 'no-useless-catch': 'error', + 'no-useless-concat': 'error', + 'no-useless-escape': 'error', + 'no-useless-return': 'error', + 'no-void': 'error', + 'no-with': 'error', + 'prefer-promise-reject-errors': [ + 'error', + { + allowEmptyReject: true, + }, + ], + 'prefer-regex-literals': [ + 'error', + { + disallowRedundantWrapping: true, + }, + ], + radix: 'error', + yoda: 'error', + 'for-direction': 'error', + 'getter-return': [ + 'error', + { + allowImplicit: true, + }, + ], + 'no-async-promise-executor': 'error', + 'no-await-in-loop': 'error', + 'no-compare-neg-zero': 'error', + 'no-cond-assign': ['error', 'always'], + 'no-console': [ + 'error', + { + allow: ['debug', 'error'], + }, + ], + 'no-constant-condition': 'error', + 'no-control-regex': 'error', + 'no-debugger': 'error', + 'no-dupe-else-if': 'error', + 'no-dupe-keys': 'error', + 'no-duplicate-case': 'error', + 'no-empty': 'error', + 'no-empty-character-class': 'error', + 'no-ex-assign': 'error', + 'no-extra-boolean-cast': 'error', + 'no-func-assign': 'error', + 'no-import-assign': 'error', + 'no-inner-declarations': 'error', + 'no-invalid-regexp': 'error', + 'no-irregular-whitespace': 'error', + 'no-loss-of-precision': 'error', + 'no-misleading-character-class': 'error', + 'no-obj-calls': 'error', + 'no-promise-executor-return': 'error', + 'no-prototype-builtins': 'error', + 'no-regex-spaces': 'error', + 'no-setter-return': 'error', + 'no-sparse-arrays': 'error', + 'no-template-curly-in-string': 'error', + 'no-unreachable': 'error', + 'no-unsafe-finally': 'error', + 'no-unsafe-negation': 'error', + 'no-unsafe-optional-chaining': [ + 'error', + { + disallowArithmeticOperators: true, + }, + ], + 'no-useless-backreference': 'error', + 'use-isnan': 'error', + 'valid-typeof': [ + 'error', + { + requireStringLiterals: true, + }, + ], + 'arrow-body-style': [ + 'error', + 'as-needed', + { + requireReturnForObjectLiteral: false, + }, + ], + 'constructor-super': 'error', + 'no-class-assign': 'error', + 'no-const-assign': 'error', + 'no-dupe-class-members': 'error', + 'no-this-before-super': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-constructor': 'error', + 'no-useless-rename': [ + 'error', + { + ignoreDestructuring: false, + ignoreImport: false, + ignoreExport: false, + }, + ], + 'no-var': 'error', + 'object-shorthand': [ + 'error', + 'always', + { + ignoreConstructors: false, + avoidQuotes: true, + }, + ], + 'prefer-arrow-callback': [ + 'error', + { + allowNamedFunctions: false, + allowUnboundThis: true, + }, + ], + 'prefer-const': [ + 'error', + { + destructuring: 'any', + ignoreReadBeforeAssign: true, + }, + ], + 'prefer-numeric-literals': 'error', + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'prefer-template': 'error', + 'require-yield': 'error', + 'symbol-description': 'error', + 'import/no-named-as-default': 'error', + 'import/no-named-as-default-member': 'error', + 'import/no-mutable-exports': 'error', + 'import/no-amd': 'error', + 'import/first': 'error', + 'import/no-duplicates': 'error', + 'import/extensions': [ + 'error', + 'ignorePackages', + { + js: 'never', + mjs: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + cts: 'never', + mts: 'never', + }, + ], + 'import/newline-after-import': 'error', + 'import/prefer-default-export': 'error', + 'import/no-absolute-path': 'error', + 'import/no-webpack-loader-syntax': 'error', + 'import/no-named-default': 'error', + 'import/no-self-import': 'error', + 'import/no-cycle': 'error', + 'no-array-constructor': 'error', + 'no-bitwise': 'error', + 'no-lonely-if': 'error', + 'no-multi-assign': ['error'], + 'no-nested-ternary': 'error', + 'no-underscore-dangle': [ + 'error', + { + allow: ['__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'], + allowAfterThis: false, + allowAfterSuper: false, + enforceInMethodNames: true, + }, + ], + 'no-unneeded-ternary': [ + 'error', + { + defaultAssignment: false, + }, + ], + 'one-var': ['error', 'never'], + 'operator-assignment': ['error', 'always'], + 'prefer-exponentiation-operator': 'error', + 'prefer-object-spread': 'error', + 'unicorn/prefer-set-has': 'error', + 'unicorn/no-array-for-each': 'error', + 'unicorn/prefer-array-find': 'error', + 'unicorn/prefer-set-size': 'error', + 'unicorn/prefer-string-replace-all': 'error', + 'no-delete-var': 'error', + 'no-label-var': 'error', + 'no-restricted-globals': [ + 'error', + { + name: 'isFinite', + message: 'Use Number.isFinite instead https://github.com/airbnb/javascript#standard-library--isfinite', + }, + { + name: 'isNaN', + message: 'Use Number.isNaN instead https://github.com/airbnb/javascript#standard-library--isnan', + }, + 'addEventListener', + 'blur', + 'close', + 'closed', + 'confirm', + 'defaultStatus', + 'defaultstatus', + 'event', + 'external', + 'find', + 'focus', + 'frameElement', + 'frames', + 'history', + 'innerHeight', + 'innerWidth', + 'length', + 'location', + 'locationbar', + 'menubar', + 'moveBy', + 'moveTo', + 'name', + 'onblur', + 'onerror', + 'onfocus', + 'onload', + 'onresize', + 'onunload', + 'open', + 'opener', + 'opera', + 'outerHeight', + 'outerWidth', + 'pageXOffset', + 'pageYOffset', + 'parent', + 'print', + 'removeEventListener', + 'resizeBy', + 'resizeTo', + 'screen', + 'screenLeft', + 'screenTop', + 'screenX', + 'screenY', + 'scroll', + 'scrollbars', + 'scrollBy', + 'scrollTo', + 'scrollX', + 'scrollY', + 'self', + 'status', + 'statusbar', + 'stop', + 'toolbar', + 'top', + ], + 'no-shadow': 'error', + 'no-shadow-restricted-names': 'error', + 'no-unused-vars': [ + 'error', + { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: true, + }, + ], + 'no-use-before-define': [ + 'error', + { + functions: true, + classes: true, + variables: true, + }, + ], + 'react/jsx-boolean-value': [ + 'error', + 'never', + { + always: [], + }, + ], + 'react/jsx-no-duplicate-props': 'error', + 'react/jsx-no-undef': 'error', + 'react/jsx-pascal-case': [ + 'error', + { + allowAllCaps: true, + ignore: [], + }, + ], + 'react/no-danger': 'error', + 'react/no-did-update-set-state': 'error', + 'react/no-will-update-set-state': 'error', + 'react/no-is-mounted': 'error', + 'react/no-string-refs': 'error', + 'react/no-unknown-property': 'error', + 'react/prefer-es6-class': ['error', 'always'], + 'react/self-closing-comp': 'error', + 'react/jsx-no-target-blank': [ + 'error', + { + enforceDynamicLinks: 'always', + }, + ], + 'react/jsx-filename-extension': [ + 'warn', + { + extensions: ['.js', '.jsx', '.tsx'], + }, + ], + 'react/jsx-no-comment-textnodes': 'error', + 'react/no-render-return-value': 'error', + 'react/no-find-dom-node': 'error', + 'react/no-array-index-key': 'error', + 'react/void-dom-elements-no-children': 'error', + 'react/no-redundant-should-component-update': 'error', + 'react/button-has-type': [ + 'error', + { + button: true, + submit: true, + reset: false, + }, + ], + 'react/no-this-in-sfc': 'error', + 'react/no-unsafe': [ + 'error', + { + checkAliases: true, + }, + ], + 'react/jsx-fragments': ['error', 'syntax'], + 'react/state-in-constructor': ['error', 'always'], + 'react/jsx-no-script-url': [ + 'error', + [ + { + name: 'Link', + props: ['to'], + }, + ], + ], + 'react/jsx-no-useless-fragment': 'error', + 'react/function-component-definition': [ + 'error', + { + namedComponents: 'function-declaration', + unnamedComponents: 'arrow-function', + }, + ], + 'react/jsx-no-constructed-context-values': 'error', + 'react/no-unstable-nested-components': 'error', + 'react/no-namespace': 'error', + 'react/style-prop-object': 'error', + 'react/no-unescaped-entities': 'error', + 'react/no-children-prop': 'error', + 'jsx-a11y/alt-text': [ + 'error', + { + elements: ['img', 'object', 'area', 'input[type="image"]'], + img: [], + object: [], + area: [], + 'input[type="image"]': [], + }, + ], + 'jsx-a11y/anchor-has-content': [ + 'error', + { + components: [], + }, + ], + 'jsx-a11y/anchor-is-valid': [ + 'error', + { + components: ['Link'], + specialLink: ['to'], + aspects: ['noHref', 'invalidHref', 'preferButton'], + }, + ], + 'jsx-a11y/aria-activedescendant-has-tabindex': 'error', + 'jsx-a11y/aria-props': 'error', + 'jsx-a11y/aria-proptypes': 'error', + 'jsx-a11y/aria-role': [ + 'error', + { + ignoreNonDOM: false, + }, + ], + 'jsx-a11y/aria-unsupported-elements': 'error', + 'jsx-a11y/control-has-associated-label': [ + 'error', + { + labelAttributes: ['label'], + controlComponents: [], + ignoreElements: ['audio', 'canvas', 'embed', 'input', 'textarea', 'tr', 'video'], + ignoreRoles: ['grid', 'listbox', 'menu', 'menubar', 'radiogroup', 'row', 'tablist', 'toolbar', 'tree', 'treegrid'], + depth: 5, + }, + ], + 'jsx-a11y/heading-has-content': [ + 'error', + { + components: [''], + }, + ], + 'jsx-a11y/html-has-lang': 'error', + 'jsx-a11y/iframe-has-title': 'error', + 'jsx-a11y/img-redundant-alt': 'error', + 'jsx-a11y/interactive-supports-focus': 'error', + 'jsx-a11y/label-has-associated-control': [ + 'error', + { + labelComponents: [], + labelAttributes: [], + controlComponents: [], + assert: 'either', + depth: 25, + }, + ], + 'jsx-a11y/lang': 'error', + 'jsx-a11y/media-has-caption': [ + 'error', + { + audio: [], + video: [], + track: [], + }, + ], + 'jsx-a11y/mouse-events-have-key-events': 'error', + 'jsx-a11y/no-access-key': 'error', + 'jsx-a11y/no-autofocus': [ + 'error', + { + ignoreNonDOM: true, + }, + ], + 'jsx-a11y/no-distracting-elements': [ + 'error', + { + elements: ['marquee', 'blink'], + }, + ], + 'jsx-a11y/no-interactive-element-to-noninteractive-role': [ + 'error', + { + tr: ['none', 'presentation'], + }, + ], + 'jsx-a11y/no-noninteractive-element-interactions': [ + 'error', + { + handlers: ['onClick', 'onMouseDown', 'onMouseUp', 'onKeyPress', 'onKeyDown', 'onKeyUp'], + }, + ], + 'jsx-a11y/no-noninteractive-element-to-interactive-role': [ + 'error', + { + ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], + ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], + li: ['menuitem', 'option', 'row', 'tab', 'treeitem'], + table: ['grid'], + td: ['gridcell'], + }, + ], + 'jsx-a11y/no-noninteractive-tabindex': [ + 'error', + { + tags: [], + roles: ['tabpanel'], + }, + ], + 'jsx-a11y/no-redundant-roles': 'error', + 'jsx-a11y/no-static-element-interactions': [ + 'error', + { + handlers: ['onClick', 'onMouseDown', 'onMouseUp', 'onKeyPress', 'onKeyDown', 'onKeyUp'], + }, + ], + 'jsx-a11y/role-has-required-aria-props': 'error', + 'jsx-a11y/role-supports-aria-props': 'error', + 'jsx-a11y/scope': 'error', + 'jsx-a11y/tabindex-no-positive': 'error', + 'react/rules-of-hooks': 'error', + 'react/exhaustive-deps': 'error', + 'react/static-components': 'error', + 'react/use-memo': 'error', + 'react/preserve-manual-memoization': 'error', + 'react/incompatible-library': 'error', + 'react/immutability': 'error', + 'react/globals': 'error', + 'react/refs': 'error', + 'react/set-state-in-effect': 'error', + 'react/error-boundaries': 'error', + 'react/purity': 'error', + 'react/set-state-in-render': 'error', + 'react/unsupported-syntax': 'error', + 'typescript/dot-notation': [ + 'error', + { + allowKeywords: true, + }, + ], + 'core/no-octal': 'error', + 'core/no-octal-escape': 'error', + 'core/no-return-await': 'error', + 'core/no-dupe-args': 'error', + 'core/no-unreachable-loop': [ + 'error', + { + ignore: [], + }, + ], + 'core/no-new-symbol': 'error', + 'core/no-restricted-exports': [ + 'error', + { + restrictedNamedExports: ['default', 'then'], + }, + ], + 'core/no-restricted-syntax': [ + 'error', + { + selector: 'ForInStatement', + message: + 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', + }, + { + selector: 'LabeledStatement', + message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', + }, + { + selector: 'WithStatement', + message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize. It is also deprecated.', + }, + ], + 'hosted/export': 'error', + 'hosted/no-extraneous-dependencies': 'off', + 'hosted/order': [ + 'error', + { + groups: [['builtin', 'external', 'internal']], + }, + ], + 'hosted/no-useless-path-segments': [ + 'error', + { + commonjs: true, + }, + ], + 'hosted/no-import-module-exports': [ + 'error', + { + exceptions: [], + }, + ], + 'hosted/no-relative-packages': 'error', + 'core/strict': ['error', 'never'], + 'core/no-new-object': 'error', + 'core/no-undef-init': 'error', + 'hosted/forbid-prop-types': [ + 'error', + { + forbid: ['any', 'array', 'object'], + checkContextTypes: true, + checkChildContextTypes: true, + }, + ], + 'hosted/jsx-no-bind': [ + 'error', + { + ignoreRefs: true, + allowArrowFunctions: true, + allowFunctions: false, + allowBind: false, + ignoreDOMComponents: true, + }, + ], + 'hosted/no-deprecated': ['error'], + 'hosted/prefer-stateless-function': [ + 'error', + { + ignorePureComponents: true, + }, + ], + 'hosted/require-render-return': 'error', + 'hosted/sort-comp': [ + 'error', + { + order: [ + 'static-variables', + 'static-methods', + 'instance-variables', + 'lifecycle', + '/^handle.+$/', + '/^on.+$/', + 'getters', + 'setters', + '/^(get|set)(?!(InitialState$|DefaultProps$|ChildContext$)).+$/', + 'instance-methods', + 'everything-else', + 'rendering', + ], + groups: { + lifecycle: [ + 'displayName', + 'propTypes', + 'contextTypes', + 'childContextTypes', + 'mixins', + 'statics', + 'defaultProps', + 'constructor', + 'getDefaultProps', + 'getInitialState', + 'state', + 'getChildContext', + 'getDerivedStateFromProps', + 'componentWillMount', + 'UNSAFE_componentWillMount', + 'componentDidMount', + 'componentWillReceiveProps', + 'UNSAFE_componentWillReceiveProps', + 'shouldComponentUpdate', + 'componentWillUpdate', + 'UNSAFE_componentWillUpdate', + 'getSnapshotBeforeUpdate', + 'componentDidUpdate', + 'componentDidCatch', + 'componentWillUnmount', + ], + rendering: ['/^render.+$/', 'render'], + }, + }, + ], + 'hosted/forbid-foreign-prop-types': [ + 'error', + { + allowInPropTypes: true, + }, + ], + 'hosted/default-props-match-prop-types': [ + 'error', + { + allowRequiredDefaults: false, + }, + ], + 'hosted/no-unused-state': 'error', + 'hosted/no-typos': 'error', + 'hosted/no-access-state-in-setstate': 'error', + 'hosted/static-property-placement': ['error', 'property assignment'], + 'hosted/prefer-exact-props': 'error', + 'hosted/no-arrow-function-lifecycle': 'error', + 'hosted/no-invalid-html-attribute': 'error', + 'hosted/no-unused-class-component-methods': 'error', + }, + overrides: [ + { + files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], + plugins: ['jsdoc'], + rules: { + 'no-redeclare': 'error', + 'jsdoc/require-param': 'error', + 'jsdoc/require-param-type': 'error', + 'jsdoc/check-tag-names': 'error', + }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], + rules: { + 'constructor-super': 'off', + 'getter-return': 'off', + 'no-class-assign': 'off', + 'no-const-assign': 'off', + 'no-dupe-class-members': 'error', + 'no-dupe-keys': 'off', + 'no-func-assign': 'off', + 'no-import-assign': 'off', + 'no-new-native-nonconstructor': 'off', + 'no-obj-calls': 'off', + 'no-redeclare': 'error', + 'no-setter-return': 'off', + 'no-this-before-super': 'off', + 'no-unreachable': 'off', + 'no-unsafe-negation': 'off', + 'no-with': 'off', + 'prefer-const': [ + 'error', + { + destructuring: 'any', + ignoreReadBeforeAssign: true, + }, + ], + 'no-implied-eval': 'off', + 'no-unused-expressions': [ + 'error', + { + allowShortCircuit: false, + allowTernary: false, + allowTaggedTemplates: false, + }, + ], + 'no-unused-vars': [ + 'error', + { + vars: 'all', + args: 'after-used', + caughtErrors: 'none', + ignoreRestSiblings: true, + reportVarsOnlyUsedAsTypes: true, + }, + ], + 'no-throw-literal': 'error', + 'prefer-promise-reject-errors': [ + 'error', + { + allowEmptyReject: true, + }, + ], + 'typescript/await-thenable': 'error', + 'typescript/ban-ts-comment': 'error', + 'typescript/no-array-delete': 'error', + 'typescript/no-base-to-string': 'error', + 'typescript/no-duplicate-enum-values': 'error', + 'typescript/no-duplicate-type-constituents': [ + 'error', + { + ignoreUnions: true, + }, + ], + 'typescript/no-empty-object-type': 'error', + 'typescript/no-explicit-any': 'error', + 'typescript/no-extra-non-null-assertion': 'error', + 'typescript/no-floating-promises': 'off', + 'typescript/no-for-in-array': 'error', + 'typescript/no-implied-eval': 'error', + 'typescript/no-misused-new': 'error', + 'typescript/no-misused-promises': 'error', + 'typescript/no-namespace': 'error', + 'typescript/no-non-null-asserted-optional-chain': 'error', + 'typescript/no-redundant-type-constituents': 'error', + 'typescript/no-require-imports': 'off', + 'typescript/no-this-alias': 'error', + 'typescript/no-unnecessary-type-assertion': 'error', + 'typescript/no-unnecessary-type-constraint': 'error', + 'typescript/no-unsafe-argument': 'error', + 'typescript/no-unsafe-assignment': 'error', + 'typescript/no-unsafe-call': 'error', + 'typescript/no-unsafe-declaration-merging': 'error', + 'typescript/no-unsafe-enum-comparison': 'error', + 'typescript/no-unsafe-function-type': 'error', + 'typescript/no-unsafe-member-access': 'error', + 'typescript/no-unsafe-return': 'error', + 'typescript/no-unsafe-unary-minus': 'error', + 'typescript/no-wrapper-object-types': 'error', + 'typescript/only-throw-error': 'off', + 'typescript/prefer-as-const': 'error', + 'typescript/prefer-namespace-keyword': 'error', + 'typescript/prefer-promise-reject-errors': 'off', + 'typescript/require-await': 'off', + 'typescript/restrict-plus-operands': 'error', + 'typescript/restrict-template-expressions': 'error', + 'typescript/triple-slash-reference': 'error', + 'typescript/unbound-method': 'error', + 'typescript/adjacent-overload-signatures': 'error', + 'typescript/array-type': [ + 'error', + { + default: 'array-simple', + }, + ], + 'typescript/ban-tslint-comment': 'error', + 'typescript/class-literal-property-style': 'error', + 'typescript/consistent-generic-constructors': 'error', + 'typescript/consistent-indexed-object-style': 'error', + 'typescript/consistent-type-assertions': 'error', + 'typescript/consistent-type-definitions': ['error', 'type'], + 'typescript/no-confusing-non-null-assertion': 'error', + 'typescript/no-inferrable-types': 'error', + 'typescript/non-nullable-type-assertion-style': 'error', + 'typescript/prefer-find': 'off', + 'typescript/prefer-for-of': 'error', + 'typescript/prefer-function-type': 'error', + 'typescript/prefer-includes': 'off', + 'typescript/prefer-nullish-coalescing': [ + 'error', + { + ignoreIfStatements: true, + ignorePrimitives: {}, + ignoreTernaryTests: true, + }, + ], + 'typescript/prefer-regexp-exec': 'off', + 'typescript/prefer-string-starts-ends-with': 'error', + 'no-new-func': 'off', + 'no-use-before-define': [ + 'error', + { + functions: false, + }, + ], + 'import/extensions': [ + 'error', + 'ignorePackages', + { + js: 'never', + mjs: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + cts: 'never', + mts: 'never', + }, + ], + 'valid-typeof': 'off', + 'import/no-named-as-default-member': 'off', + 'unicorn/prefer-at': 'error', + 'rulesdir/prefer-underscore-method': 'off', + 'jsdoc/require-param': 'off', + 'max-params': [ + 'error', + { + max: 10, + }, + ], + 'typescript/return-await': ['error', 'in-try-catch'], + 'typescript/no-var-requires': 'off', + 'typescript/no-unsafe-type-assertion': 'error', + 'typescript/switch-exhaustiveness-check': [ + 'error', + { + considerDefaultExhaustiveForUnions: true, + }, + ], + 'typescript/no-restricted-types': [ + 'error', + { + types: { + object: "Use 'Record' instead.", + }, + }, + ], + 'typescript/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + fixStyle: 'separate-type-imports', + }, + ], + 'typescript/consistent-type-exports': [ + 'error', + { + fixMixedExportsWithInlineTypeSpecifier: false, + }, + ], + 'typescript/no-deprecated': 'error', + 'typescript/no-non-null-assertion': 'error', + 'typescript/prefer-enum-initializers': 'error', + 'typescript/no-import-type-side-effects': 'error', + }, + plugins: ['jsdoc'], + }, + { + files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], + rules: { + 'typescript/await-thenable': 'off', + 'typescript/consistent-type-exports': 'off', + 'typescript/dot-notation': 'off', + 'typescript/no-array-delete': 'off', + 'typescript/no-base-to-string': 'off', + 'typescript/no-confusing-void-expression': 'off', + 'typescript/no-deprecated': 'off', + 'typescript/no-duplicate-type-constituents': 'off', + 'typescript/no-floating-promises': 'off', + 'typescript/no-for-in-array': 'off', + 'typescript/no-generated-empty-object-type': 'off', + 'typescript/no-implied-eval': 'off', + 'typescript/no-meaningless-void-operator': 'off', + 'typescript/no-misused-promises': 'off', + 'typescript/no-misused-spread': 'off', + 'typescript/no-mixed-enums': 'off', + 'typescript/no-redundant-type-constituents': 'off', + 'typescript/no-unnecessary-boolean-literal-compare': 'off', + 'typescript/no-unnecessary-qualifier': 'off', + 'typescript/no-unnecessary-template-expression': 'off', + 'typescript/no-unnecessary-type-arguments': 'off', + 'typescript/no-unnecessary-type-assertion': 'off', + 'typescript/no-unnecessary-type-conversion': 'off', + 'typescript/no-unnecessary-type-parameters': 'off', + 'typescript/no-unsafe-argument': 'off', + 'typescript/no-unsafe-assignment': 'off', + 'typescript/no-unsafe-call': 'off', + 'typescript/no-unsafe-enum-assignment': 'off', + 'typescript/no-unsafe-enum-comparison': 'off', + 'typescript/no-unsafe-member-access': 'off', + 'typescript/no-unsafe-return': 'off', + 'typescript/no-unsafe-type-assertion': 'off', + 'typescript/no-unsafe-unary-minus': 'off', + 'typescript/no-useless-default-assignment': 'off', + 'typescript/non-nullable-type-assertion-style': 'off', + 'typescript/only-throw-error': 'off', + 'typescript/prefer-find': 'off', + 'typescript/prefer-includes': 'off', + 'typescript/prefer-nullish-coalescing': 'off', + 'typescript/prefer-promise-reject-errors': 'off', + 'typescript/prefer-readonly': 'off', + 'typescript/prefer-readonly-parameter-types': 'off', + 'typescript/prefer-reduce-type-parameter': 'off', + 'typescript/prefer-regexp-exec': 'off', + 'typescript/prefer-return-this-type': 'off', + 'typescript/prefer-string-starts-ends-with': 'off', + 'typescript/promise-function-async': 'off', + 'typescript/related-getter-setter-pairs': 'off', + 'typescript/require-array-sort-compare': 'off', + 'typescript/require-await': 'off', + 'typescript/restrict-plus-operands': 'off', + 'typescript/restrict-template-expressions': 'off', + 'typescript/return-await': 'off', + 'typescript/strict-boolean-expressions': 'off', + 'typescript/strict-void-return': 'off', + 'typescript/switch-exhaustiveness-check': 'off', + 'typescript/unbound-method': 'off', + 'typescript/use-unknown-in-catch-callback-variable': 'off', + }, + }, + { + files: ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts'], + plugins: ['jsdoc'], + env: { + jest: true, + }, + rules: { + 'no-console': 'off', + 'no-await-in-loop': 'off', + 'no-import-assign': 'off', + 'jsdoc/check-tag-names': [ + 'error', + { + definedTags: ['jest-environment'], + }, + ], + }, + }, + { + files: ['**/scripts/**', '**/server/**', '**/.github/**'], + rules: { + 'no-console': 'off', + }, + }, + { + files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], + env: { + commonjs: true, + node: true, + }, + }, + { + files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], + rules: { + 'import/extensions': 'off', + }, + }, + { + files: ['lib/storage/providers/MemoryOnlyProvider.ts'], + env: { + jest: true, + }, + }, + { + files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], + rules: { + 'typescript/dot-notation': 'off', + }, + }, + { + files: ['tests/perf-test/**/*'], + rules: { + 'unicorn/prefer-at': 'off', + }, + }, + { + files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], + rules: { + 'hosted/named': 'error', + 'hosted/check-param-names': 'error', + 'hosted/check-types': [ + 'error', + { + exemptTagContexts: [ + { + tag: 'param', + types: ['Object'], + }, + { + tag: 'returns', + types: ['Object'], + }, + ], + noDefaults: true, + }, + ], + }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts'], + rules: { + 'hosted/no-types': 'error', + }, + }, + { + files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], + rules: { + 'core/no-undef': 'error', + }, + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], + rules: { + 'core/no-dupe-args': 'off', + 'core/no-new-symbol': 'off', + 'core/no-undef': 'off', + 'core/no-return-await': 'off', + 'hosted/no-extraneous-dependencies': [ + 'error', + { + devDependencies: [ + 'tests/**', + 'test/**', + 'spec/**', + '**/__tests__/**', + '**/__mocks__/**', + 'test.{js,jsx}', + 'test.{ts,tsx}', + 'test-*.{js,jsx}', + 'test-*.{ts,tsx}', + '**/*.{test,spec}.{js,jsx,mjs,cjs}', + '**/*.{test,spec}.{ts,tsx,mjs,cjs}', + '**/*.{test,spec}.{ts,tsx,cts,mts}', + '**/*_test.{js,jsx,ts,tsx}', + '**/*_test.{ts,tsx,ts,tsx}', + '**/jest.config.{js,ts,mts}', + '**/jest.config.{ts,ts,mts}', + '**/jest.setup.{js,ts}', + '**/jest.setup.{ts,ts}', + 'jestSetup.js', + 'jestSetup.ts', + '**/vue.config.js', + '**/vue.config.ts', + '**/webpack.config.js', + '**/webpack.config.ts', + '**/webpack.config.*.js', + '**/webpack.config.*.ts', + '**/rollup.config.js', + '**/rollup.config.ts', + '**/rollup.config.*.js', + '**/rollup.config.*.ts', + '**/gulpfile.js', + '**/gulpfile.ts', + '**/gulpfile.*.js', + '**/gulpfile.*.ts', + '**/Gruntfile{,.js}', + '**/Gruntfile{,.ts}', + '**/protractor.conf.js', + '**/protractor.conf.ts', + '**/protractor.conf.*.js', + '**/protractor.conf.*.ts', + '**/karma.conf.js', + '**/karma.conf.ts', + '**/.eslintrc.js', + '**/.eslintrc.ts', + '**/vitest.config.{ts,mts}', + '**/vite.config.{ts,mts}', + '**/eslint.config.{mjs,mts}', + ], + optionalDependencies: false, + }, + ], + 'hosted/named': 'off', + 'hosted/naming-convention': [ + 'error', + { + selector: ['variable', 'property'], + format: null, + filter: { + regex: '^__esModule$', + match: true, + }, + }, + { + selector: ['variable', 'property'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + filter: { + regex: '^private_[a-z][a-zA-Z0-9]*$', + match: false, + }, + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + { + selector: ['typeLike', 'enumMember'], + format: ['PascalCase'], + }, + { + selector: ['parameter', 'method'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + ], + 'core/lines-between-class-members': [ + 'error', + 'always', + { + exceptAfterSingleLine: false, + }, + ], + }, + }, + { + files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], + rules: { + 'hosted/naming-convention': 'off', + }, + }, + { + files: ['**/scripts/**', '**/server/**', '**/.github/**'], + rules: { + 'hosted/no-extraneous-dependencies': 'off', + }, + }, + { + files: ['tests/**/*'], + rules: { + 'hosted/naming-convention': [ + 'error', + { + selector: ['variable', 'property'], + format: null, + filter: { + regex: '^__esModule$', + match: true, + }, + }, + { + selector: ['variable', 'property'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + filter: { + regex: '^private_[a-z][a-zA-Z0-9]*$', + match: false, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '_', + match: true, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '^[0-9]+$', + match: true, + }, + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + { + selector: ['typeLike', 'enumMember'], + format: ['PascalCase'], + }, + { + selector: ['parameter', 'method'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + ], + }, + }, + ], +}); diff --git a/package-lock.json b/package-lock.json index cadc07efd..9abfee5bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,8 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", + "oxlint": "1.90.0", + "oxlint-tsgolint": "7.0.2005", "prettier": "^2.8.8", "prop-types": "^15.7.2", "react": "18.2.0", @@ -3588,6 +3590,437 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2005.tgz", + "integrity": "sha512-VqDZxcZVjlonNLuuvraVNgDQt+1ndwa4yJo8OqsK4HYh/61ba9IlMStCVfi/aaIqRoDhNbAKC8gdcTrXqnb+Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2005.tgz", + "integrity": "sha512-4kUfLRNYoVpv0Tm3jPYrvxyfOc/LmdvFC3pKZ4tL6K7lA7K/VS81SAtmxRzbYXYSyb3SBWhwsuX+df4neqzxxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2005.tgz", + "integrity": "sha512-rhUNcPsBlKvhXtrbhbBUidtRBRdJ8fbEHN9H5Q5N59gby18ruGuDA7KUxl7oRnUzp797uykYQhkBDm+0KpfK/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2005.tgz", + "integrity": "sha512-tKs9yinDQZOHygM0e8EJnjTU3mzJtdfkOZP6Udu82iLMePUSHOhTJ9zzU/mSd1hQxURI4LTRRiFhfzC+PzwdNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2005.tgz", + "integrity": "sha512-VYPnu1gjfbSv+ROhuyNfMs382D4zQU2ZRc8Uc3nuEZo1k2kf+1KfTrg4hrlWh73h9GI+dUWyo+DYCQzUBlPwcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2005.tgz", + "integrity": "sha512-ekU89cTGy9fpQzuvxIsFHrnoP8XoFjGIRgPEW6eqgY2NYjGy9pwpjF79o04WXRMJFKRrcaVqkZXUUBdLsqpQDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.90.0.tgz", + "integrity": "sha512-sgfalb1HuxYjiAa4wESZ626z1FGusqNcXYHyB5oIOEprS2sbPg0UYEfo83uF1GNG70DIfZxHkXuE6KFrlSdn8Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.90.0.tgz", + "integrity": "sha512-RcKhz1ETMq+fazE9TKS2+5NIRGwigm/nW8egqF0LI+A8Yw2iW5r0E/C3XAPRiwIm/vcPiutIRIa6IQCIKhZsTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.90.0.tgz", + "integrity": "sha512-PSrxMvP6mx+p1karZcUxLdy9lpaP+JWQPti4al9FgTaPhxE+pT/BjhGExloVVSWgZgIfOlLOjVyyq8BXlodhLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.90.0.tgz", + "integrity": "sha512-OLbU9qsrV2Q8QxT2OEzOPdO8zCCpyI8qB+hDI8yr6RsYELtkf0FAZhJdfjM/RnLHKzt44HWwAPnk92vBtKofvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.90.0.tgz", + "integrity": "sha512-fBA4pWyVDARmNucQqpyFuPC49xYf1r+sX7dxPL5fNPQrSMLum39Sn56tZ9JPleWC7FzLPwSQ7plYYfirYBHF8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.90.0.tgz", + "integrity": "sha512-zhxH78eP2nR6/pGdhBc7MIfjhhcgATeu8fkmkFJbuzfk6Cj0FzBxyEhL85yDqrCybjx+G2YDzZGsxIO9kvO4Iw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.90.0.tgz", + "integrity": "sha512-MvYmpHyJqUJjEvkZbJHdwfdL45cGn8EVBkYmoXFiaYFW9suoD3dbJl5mBdpy0DVgdjlyddmldbRtk3cCgETadQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.90.0.tgz", + "integrity": "sha512-L13Sy+Kg0nZhwTsToniqjUFPA2TuM6962qCZ5Wp5KeB517Tw7ApdLTTtlb+bF86fcZZa2EtvCVvAJxk4Dya/MQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.90.0.tgz", + "integrity": "sha512-wFDdZWGpzIPFdJFjSLWnKB0TGzFriTKUIrl04iMqMyM9BBIJTuerfCHYa0WpDSjr5x3lcW/CHLyacSt886WpGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.90.0.tgz", + "integrity": "sha512-FRywfn+MoKn8xpBSzgNwNVliSKRI2QXkxi/iF7o6FEQs8Bh8z2RpVTaFAcO3pchXjvB2eGNFIoLLQABIQ+u7VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.90.0.tgz", + "integrity": "sha512-IS2PaUgpc/qeLADTyhUNmdx3x4ijs9pjJdPKBYHaT+mAE7k3FsjnHPTjhpsak1b++h6CIPhv6WX9Z+y7Pw0voQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.90.0.tgz", + "integrity": "sha512-s7JFKRkGS8O7i2ZVODfuO++NC7sH6HDqpMn8lYxw91w0SXJOxnAVjQ8utvmDwLMMT0jaSkuOKuUYJDGDtu4sHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.90.0.tgz", + "integrity": "sha512-b0GxrOQr5VONCkd7KLSqmP6b6nqZuTpuS8QPQHzltmqdXP5bY0Jl0CZ0QmlCTrS3ExaLYbioFYOARzNbYvcApQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.90.0.tgz", + "integrity": "sha512-iaJeShJtgUp8rZbt9YUdTAGOpmX5KogCLba17Jp1tWdJ3F4M3D2g2TKUyI1FMTHxKH9NNHuQcuC2kZRg6IV7yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.90.0.tgz", + "integrity": "sha512-/aZoXnL5WBLCXuzfHqvEFXcm4iwYmr/cYByHDTXzLFzJdE5DC7bm6phpWqHv6BeqpVgY9IpBAxGDv2WEkl5JCg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.90.0.tgz", + "integrity": "sha512-SMhk7ozwoxYDKfYqGZCQEfULOKDneF9uhaoIf2neoDLWvNtjY9yVeL7YHmE5L9WdKvMpWNwwZxhenxBu6AgfOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.90.0.tgz", + "integrity": "sha512-KBvR3A9fO7fCi2vCP8ifWvlia7tGEQV9NaYh+IGwa/7Groy48Mt/UaYqMK9/zOtAWl20BUCf6RPqzJ32fRw33Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.90.0.tgz", + "integrity": "sha512-+x42rDHQGbTCuT3EzpWAzhP3NoVPE/cJsG4gnrakOSg0olV+GCdMpPU23Nvrl3zZMnXxFZdTtDv4D3SuaA5ZeQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.90.0.tgz", + "integrity": "sha512-30JiaFEBUB9peEOcvVM8ggo88SoN/IdSr9DW505JdBji/R6dM66gKEiG65hw6qcaF2f4mO0cEJ+RwRZprmun9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@react-native-community/eslint-config": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz", @@ -13223,6 +13656,73 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxlint": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.90.0.tgz", + "integrity": "sha512-RkTN9EmgIUpLuhNoLhO3OliMGJlvb3Dk7XVjBZnfoMCHrVpuz5ESd97klfiyYeV7XfA2lNLnDCPMkUI+Qw5How==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.90.0", + "@oxlint/binding-android-arm64": "1.90.0", + "@oxlint/binding-darwin-arm64": "1.90.0", + "@oxlint/binding-darwin-x64": "1.90.0", + "@oxlint/binding-freebsd-x64": "1.90.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.90.0", + "@oxlint/binding-linux-arm-musleabihf": "1.90.0", + "@oxlint/binding-linux-arm64-gnu": "1.90.0", + "@oxlint/binding-linux-arm64-musl": "1.90.0", + "@oxlint/binding-linux-ppc64-gnu": "1.90.0", + "@oxlint/binding-linux-riscv64-gnu": "1.90.0", + "@oxlint/binding-linux-riscv64-musl": "1.90.0", + "@oxlint/binding-linux-s390x-gnu": "1.90.0", + "@oxlint/binding-linux-x64-gnu": "1.90.0", + "@oxlint/binding-linux-x64-musl": "1.90.0", + "@oxlint/binding-openharmony-arm64": "1.90.0", + "@oxlint/binding-win32-arm64-msvc": "1.90.0", + "@oxlint/binding-win32-ia32-msvc": "1.90.0", + "@oxlint/binding-win32-x64-msvc": "1.90.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2005", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint-tsgolint": { + "version": "7.0.2005", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2005.tgz", + "integrity": "sha512-6ccPGu4u4J9bwYPHXBFPee3RZrX3BboRdU7k14zRDDB1lvN3O7J/vPRFdaEfa1+CWx4xnQQOp4ctoaF66oO0uw==", + "dev": true, + "license": "MIT", + "bin": { + "tsgolint": "bin/tsgolint.js" + }, + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "7.0.2005", + "@oxlint-tsgolint/darwin-x64": "7.0.2005", + "@oxlint-tsgolint/linux-arm64": "7.0.2005", + "@oxlint-tsgolint/linux-x64": "7.0.2005", + "@oxlint-tsgolint/win32-arm64": "7.0.2005", + "@oxlint-tsgolint/win32-x64": "7.0.2005" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index 26966792c..c60664938 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,8 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", + "oxlint": "1.90.0", + "oxlint-tsgolint": "7.0.2005", "prettier": "^2.8.8", "prop-types": "^15.7.2", "react": "18.2.0", From 17c1d88498dbc7dba8bea098a41913904ccdc9c7 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 5 Nov 2026 07:49:06 +0100 Subject: [PATCH 05/43] Add ESLint vs Oxlint parity report and close config gaps --- config/oxlint/plugins/core-rules.mjs | 4 +++- config/oxlint/ruleNames.mjs | 25 ++++++++++++++++++++++++- oxlint.config.mts | 16 +++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/config/oxlint/plugins/core-rules.mjs b/config/oxlint/plugins/core-rules.mjs index 48c21372e..d9b381cb7 100644 --- a/config/oxlint/plugins/core-rules.mjs +++ b/config/oxlint/plugins/core-rules.mjs @@ -5,7 +5,8 @@ * Adapted from Expensify/App config/oxlint/plugins/core-rules.mjs. * * no-invalid-this is left out, as in App: it calls sourceCode.getJSDocComment, which Oxlint's plugin - * bridge does not support. + * bridge does not support. Verified on oxlint 1.90.0: enabling it makes the JS plugin throw on any file with + * a function, which drops every JS-plugin finding for that file. */ import {builtinRules} from 'eslint/use-at-your-own-risk'; @@ -25,6 +26,7 @@ const NAMES = [ 'no-unreachable-loop', 'no-restricted-exports', 'no-undef', + 'dot-notation', ]; const plugin = { diff --git a/config/oxlint/ruleNames.mjs b/config/oxlint/ruleNames.mjs index c97eeaf7c..35643a54d 100644 --- a/config/oxlint/ruleNames.mjs +++ b/config/oxlint/ruleNames.mjs @@ -19,6 +19,8 @@ const HOSTED_RULE_ORIGIN = { export: 'import', named: 'import', order: 'import', + // Native import/prefer-default-export ignores `export type {...}`; ESLint's reports it (tests/types.ts). + 'prefer-default-export': 'import', 'no-types': 'jsdoc', 'check-param-names': 'jsdoc', 'check-types': 'jsdoc', @@ -65,6 +67,24 @@ const TS_EXTENSION_RULES = new Set([ 'no-use-before-define', 'no-useless-constructor', ]); +// eslint-plugin-react-hooks rules that Oxlint implements inside its native `react` plugin. +const REACT_HOOKS_RULES = new Set([ + 'rules-of-hooks', + 'exhaustive-deps', + 'static-components', + 'use-memo', + 'preserve-manual-memoization', + 'incompatible-library', + 'immutability', + 'globals', + 'refs', + 'set-state-in-effect', + 'error-boundaries', + 'purity', + 'set-state-in-render', + 'unsupported-syntax', +]); + const TS_FILE = /\.(ts|tsx|mts|cts)$/; const OXLINT_DIAGNOSTIC_CODE = /^([\w@/.-]+)\((.+)\)$/; @@ -98,6 +118,9 @@ function oxlintCodeToESLintRuleID(code, filename = '') { if (plugin === 'eslint' || plugin === 'core') { return OXLINT_RULE_RENAMES[rule] ?? rule; } + if (plugin === 'react' && REACT_HOOKS_RULES.has(rule)) { + return `react-hooks/${rule}`; + } if (plugin === 'hosted') { const origin = HOSTED_RULE_ORIGIN[rule]; if (!origin) { @@ -108,4 +131,4 @@ function oxlintCodeToESLintRuleID(code, filename = '') { return `${NATIVE_PLUGIN_PREFIX[plugin] ?? plugin}/${rule}`; } -export {HOSTED_RULE_ORIGIN, OXLINT_RULE_RENAMES, TS_EXTENSION_RULES, hostedRuleNames, oxlintCodeToESLintRuleID}; +export {HOSTED_RULE_ORIGIN, OXLINT_RULE_RENAMES, REACT_HOOKS_RULES, TS_EXTENSION_RULES, hostedRuleNames, oxlintCodeToESLintRuleID}; diff --git a/oxlint.config.mts b/oxlint.config.mts index e443c8475..0df2cb1b8 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -9,6 +9,7 @@ * - core/*: ESLint core rules, with ESLint's own code. * - hosted/*: rules from ESLint plugins (typescript-eslint naming-convention, import, jsdoc, react). * - rulesdir/*: eslint-plugin-expensify rules that need no type information. + * - es/*: eslint-plugin-es, loaded as is (JS files only). * * Type-aware eslint-plugin-expensify rules cannot run in a JS plugin (Oxlint gives JS plugins no TypeScript program): * - rulesdir/prefer-at: unicorn/prefer-at stands in for it and covers fewer cases. @@ -31,6 +32,10 @@ export default defineConfig({ name: 'hosted', specifier: './config/oxlint/plugins/hosted-rules.mjs', }, + { + name: 'es', + specifier: 'eslint-plugin-es', + }, ], categories: { correctness: 'off', @@ -372,7 +377,6 @@ export default defineConfig({ }, ], 'import/newline-after-import': 'error', - 'import/prefer-default-export': 'error', 'import/no-absolute-path': 'error', 'import/no-webpack-loader-syntax': 'error', 'import/no-named-default': 'error', @@ -725,6 +729,12 @@ export default defineConfig({ allowKeywords: true, }, ], + 'core/dot-notation': [ + 'error', + { + allowKeywords: true, + }, + ], 'core/no-octal': 'error', 'core/no-octal-escape': 'error', 'core/no-return-await': 'error', @@ -766,6 +776,7 @@ export default defineConfig({ groups: [['builtin', 'external', 'internal']], }, ], + 'hosted/prefer-default-export': 'error', 'hosted/no-useless-path-segments': [ 'error', { @@ -884,6 +895,8 @@ export default defineConfig({ plugins: ['jsdoc'], rules: { 'no-redeclare': 'error', + 'es/no-nullish-coalescing-operators': 'error', + 'es/no-optional-chaining': 'error', 'jsdoc/require-param': 'error', 'jsdoc/require-param-type': 'error', 'jsdoc/check-tag-names': 'error', @@ -1243,6 +1256,7 @@ export default defineConfig({ 'core/no-dupe-args': 'off', 'core/no-new-symbol': 'off', 'core/no-undef': 'off', + 'core/dot-notation': 'off', 'core/no-return-await': 'off', 'hosted/no-extraneous-dependencies': [ 'error', From 9556a8099844d1e19c58293e1ac00ab0f43a2f65 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 5 Nov 2026 07:55:10 +0100 Subject: [PATCH 06/43] Gate ESLint and Oxlint with warning-count thresholds instead of a baseline --- config/lintDebt.mjs | 69 +++++++++++++++++++++++++++++++++++++ config/oxlint/ruleNames.mjs | 39 +++++++++++++++++++++ eslint.config.mjs | 24 +++++++++++-- oxlint.config.mts | 25 +++++++++++--- package.json | 3 +- 5 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 config/lintDebt.mjs diff --git a/config/lintDebt.mjs b/config/lintDebt.mjs new file mode 100644 index 000000000..7c0d2c165 --- /dev/null +++ b/config/lintDebt.mjs @@ -0,0 +1,69 @@ +/** + * Count-threshold lint gate (experiment, replaces a seatbelt baseline). + * + * Every rule that had violations when eslint-config-expensify 4.x was adopted is downgraded from `error` to + * `warn` in both eslint.config.mjs and oxlint.config.mts, and CI runs the linters with `--max-warnings ` + * (see the `lint` and `lint:oxlint` scripts in package.json). Errors still fail on their own, so any rule + * not listed here stays strict. Shrink this list (and the thresholds) as rules reach zero. + * + * Rule ids are ESLint ids; oxlint.config.mts maps its own keys to them with config/oxlint/ruleNames.mjs. + */ +const LINT_DEBT_RULES = new Set([ + '@typescript-eslint/consistent-type-definitions', + '@typescript-eslint/naming-convention', + '@typescript-eslint/no-deprecated', + '@typescript-eslint/no-empty-object-type', + '@typescript-eslint/no-loop-func', + '@typescript-eslint/no-misused-promises', + '@typescript-eslint/no-non-null-assertion', + '@typescript-eslint/no-redundant-type-constituents', + '@typescript-eslint/no-restricted-types', + '@typescript-eslint/no-unnecessary-type-assertion', + '@typescript-eslint/no-unsafe-argument', + '@typescript-eslint/no-unsafe-assignment', + '@typescript-eslint/no-unsafe-call', + '@typescript-eslint/no-unsafe-member-access', + '@typescript-eslint/no-unsafe-return', + '@typescript-eslint/no-unsafe-type-assertion', + '@typescript-eslint/no-unused-vars', + '@typescript-eslint/no-use-before-define', + '@typescript-eslint/non-nullable-type-assertion-style', + '@typescript-eslint/prefer-nullish-coalescing', + '@typescript-eslint/restrict-template-expressions', + '@typescript-eslint/unbound-method', + 'import/no-extraneous-dependencies', + 'jsdoc/no-types', + 'rulesdir/prefer-at', +]); + +const isError = (severity) => severity === 'error' || severity === 2; + +/** + * Turns an `error` rule entry into `warn`, keeping its options. `off` and `warn` entries are returned as is, + * so a rule is never switched on where the shared config turned it off. + * @param {unknown} entry a rule entry such as `'error'` or `['error', {...}]` + * @returns {unknown} + */ +function downgradeToWarn(entry) { + if (Array.isArray(entry)) { + return isError(entry[0]) ? ['warn', ...entry.slice(1)] : entry; + } + return isError(entry) ? 'warn' : entry; +} + +/** + * Downgrades the debt rules in a rules object. `toESLintIds` maps a rule key to the ESLint ids it stands for. + * @template {Record | undefined} T + * @param {T} rules + * @param {(key: string) => string[]} [toESLintIds] + * @returns {T} + */ +function downgradeDebtRules(rules, toESLintIds) { + if (!rules) { + return rules; + } + const isDebt = (key) => (toESLintIds ? toESLintIds(key) : [key]).some((id) => LINT_DEBT_RULES.has(id)); + return /** @type {T} */ (Object.fromEntries(Object.entries(rules).map(([key, entry]) => [key, isDebt(key) ? downgradeToWarn(entry) : entry]))); +} + +export {LINT_DEBT_RULES, downgradeDebtRules, downgradeToWarn}; diff --git a/config/oxlint/ruleNames.mjs b/config/oxlint/ruleNames.mjs index 35643a54d..dd2cb8503 100644 --- a/config/oxlint/ruleNames.mjs +++ b/config/oxlint/ruleNames.mjs @@ -131,4 +131,43 @@ function oxlintCodeToESLintRuleID(code, filename = '') { return `${NATIVE_PLUGIN_PREFIX[plugin] ?? plugin}/${rule}`; } +// Native Oxlint rules configured in place of ESLint rules that cannot run in Oxlint. +const STAND_INS = { + 'unicorn/prefer-at': 'rulesdir/prefer-at', +}; + +/** + * Maps a rule key as written in oxlint.config.mts (like `typescript/no-explicit-any`, `hosted/naming-convention` + * or `no-unused-vars`) to the ESLint rule ids it stands for. A native rule that ESLint runs as both a core rule + * and a typescript-eslint extension maps to both. + * @param {string} key + * @returns {string[]} + */ +function oxlintConfigRuleToESLintRuleIDs(key) { + if (STAND_INS[key]) { + return [STAND_INS[key]]; + } + const slash = key.indexOf('/'); + if (slash === -1) { + const name = OXLINT_RULE_RENAMES[key] ?? key; + return TS_EXTENSION_RULES.has(key) ? [name, `@typescript-eslint/${key}`] : [name]; + } + const plugin = key.slice(0, slash); + const name = key.slice(slash + 1); + if (plugin === 'core') { + return [name]; + } + if (plugin === 'hosted') { + return [`${HOSTED_RULE_ORIGIN[name]}/${name}`]; + } + if (plugin === 'typescript') { + return [`@typescript-eslint/${name}`]; + } + if (plugin === 'react' && REACT_HOOKS_RULES.has(name)) { + return [`react-hooks/${name}`]; + } + return [key]; +} + +export {STAND_INS, oxlintConfigRuleToESLintRuleIDs}; export {HOSTED_RULE_ORIGIN, OXLINT_RULE_RENAMES, REACT_HOOKS_RULES, TS_EXTENSION_RULES, hostedRuleNames, oxlintCodeToESLintRuleID}; diff --git a/eslint.config.mjs b/eslint.config.mjs index a4985352d..37d4fe7f5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,12 +10,28 @@ import globals from 'globals'; import {createRequire} from 'node:module'; import path from 'node:path'; +// oxlint-disable-next-line import/extensions -- Oxlint's port flags this; eslint-plugin-import skips paths its resolver cannot resolve without the extension. +import {downgradeDebtRules} from './config/lintDebt.mjs'; + const require = createRequire(import.meta.url); const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); -export default defineConfig([ - globalIgnores(['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**', 'lint-parity/**', 'oxlint.config.mts', 'config/oxlint/**']), +const configs = defineConfig([ + globalIgnores([ + 'dist/**', + 'node_modules/**', + '**/*.d.ts', + '**/*.config.js', + '**/*.config.cjs', + 'tests/types/**/*.ts', + 'cpp/**', + 'bench-results/**', + '.github/**', + 'lint-parity/**', + 'oxlint.config.mts', + 'config/oxlint/**', + ]), ...browserConfig, ...reactConfig, ...tsExpensifyConfig, @@ -133,3 +149,7 @@ export default defineConfig([ }, prettierConfig, ]); + +// Count-threshold gate: rules with existing violations report as warnings, and `npm run lint` fails above +// the warning count it passes to --max-warnings. See config/lintDebt.mjs. +export default configs.map((entry) => (entry.rules ? {...entry, rules: downgradeDebtRules(entry.rules)} : entry)); diff --git a/oxlint.config.mts b/oxlint.config.mts index 0df2cb1b8..0c23e2deb 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -16,8 +16,12 @@ * - rulesdir/boolean-conditional-rendering: dropped, no Oxlint equivalent. */ import {defineConfig} from 'oxlint'; +import type {OxlintConfig} from 'oxlint'; -export default defineConfig({ +import {downgradeDebtRules} from './config/lintDebt.mjs'; +import {oxlintConfigRuleToESLintRuleIDs} from './config/oxlint/ruleNames.mjs'; + +const config = defineConfig({ plugins: ['typescript', 'import', 'jsx-a11y', 'unicorn', 'react'], jsPlugins: [ { @@ -1241,7 +1245,7 @@ export default defineConfig({ { files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts'], rules: { - 'hosted/no-types': 'error', + 'hosted/no-types': 'warn', }, }, { @@ -1259,7 +1263,7 @@ export default defineConfig({ 'core/dot-notation': 'off', 'core/no-return-await': 'off', 'hosted/no-extraneous-dependencies': [ - 'error', + 'warn', { devDependencies: [ 'tests/**', @@ -1315,7 +1319,7 @@ export default defineConfig({ ], 'hosted/named': 'off', 'hosted/naming-convention': [ - 'error', + 'warn', { selector: ['variable', 'property'], format: null, @@ -1371,7 +1375,7 @@ export default defineConfig({ files: ['tests/**/*'], rules: { 'hosted/naming-convention': [ - 'error', + 'warn', { selector: ['variable', 'property'], format: null, @@ -1422,3 +1426,14 @@ export default defineConfig({ }, ], }); + +// Count-threshold gate: rules with existing violations report as warnings, and `npm run lint:oxlint` fails above +// the warning count it passes to --max-warnings. See config/lintDebt.mjs. +// Annotated because the inferred type is too large for TypeScript to serialize (declaration: true). +const gatedConfig: OxlintConfig = defineConfig({ + ...config, + rules: downgradeDebtRules(config.rules, oxlintConfigRuleToESLintRuleIDs), + overrides: config.overrides?.map((override) => ({...override, rules: downgradeDebtRules(override.rules, oxlintConfigRuleToESLintRuleIDs)})), +}); + +export default gatedConfig; diff --git a/package.json b/package.json index c60664938..630d8d621 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "types": "dist/index.d.ts", "scripts": { "prepare": "npm run build", - "lint": "eslint .", + "lint": "eslint . --max-warnings 713", + "lint:oxlint": "oxlint --type-aware --max-warnings 665", "typecheck": "tsc --noEmit", "test": "jest", "test:types": "npm run build && tsc --noEmit --project tsconfig.test.json", From 447020de96edec528f704f627f96f9036c362d62 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 5 Nov 2026 07:59:15 +0100 Subject: [PATCH 07/43] Add lint-parity README with results and gating recommendation --- config/oxlint/plugins/expensify-rules.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/oxlint/plugins/expensify-rules.mjs b/config/oxlint/plugins/expensify-rules.mjs index 01b5c702b..a3ab6c53a 100644 --- a/config/oxlint/plugins/expensify-rules.mjs +++ b/config/oxlint/plugins/expensify-rules.mjs @@ -5,8 +5,8 @@ * * Rules that call `ESLintUtils.getParserServices` (type-aware rules such as `prefer-at` and * `boolean-conditional-rendering`) cannot run here: Oxlint JS plugins get no TypeScript program. - * They are listed in TYPE_AWARE_RULES and left out, so enabling one fails loudly at config load - * instead of silently reporting nothing. `oxlint.config.mts` uses native stand-ins for them. + * They are detected by source, collected in TYPE_AWARE_RULES and not registered; `oxlint.config.mts` uses a + * native stand-in where one exists. */ import fs from 'node:fs'; import {createRequire} from 'node:module'; From 171ddf109a73e693d190952ff9d8f251b8a2f3de Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 5 Nov 2026 16:33:39 +0100 Subject: [PATCH 08/43] Derive Oxlint config from eslint-config-expensify presets at load time --- config/oxlint/derive.mjs | 339 +++++ config/oxlint/plugins/core-rules.mjs | 26 +- config/oxlint/plugins/expensify-rules.mjs | 48 - config/oxlint/plugins/hosted-rules.mjs | 65 +- config/oxlint/ruleNames.mjs | 86 +- config/oxlint/sourceConfig.mjs | 150 +++ oxlint.config.mts | 1433 +-------------------- package-lock.json | 435 ++++++- package.json | 4 +- 9 files changed, 954 insertions(+), 1632 deletions(-) create mode 100644 config/oxlint/derive.mjs delete mode 100644 config/oxlint/plugins/expensify-rules.mjs create mode 100644 config/oxlint/sourceConfig.mjs diff --git a/config/oxlint/derive.mjs b/config/oxlint/derive.mjs new file mode 100644 index 000000000..109e2b9af --- /dev/null +++ b/config/oxlint/derive.mjs @@ -0,0 +1,339 @@ +/** + * Builds the Oxlint config from config/oxlint/sourceConfig.mjs (eslint-config-expensify presets plus Onyx's + * blocks) every time Oxlint loads it, so Oxlint follows eslint-config-expensify without a hand-kept rule list. + * + * 1. `@oxlint/migrate` converts the ESLint config and reports the rules it could not convert. + * 2. Known migrate pitfalls are fixed (invalid options, jsPlugins inside overrides, missing settings). + * 3. Converted rules that Oxlint cannot run natively are run with ESLint's own code through JS plugins + * (config/oxlint/plugins): ESLint core rules as `core/`, ESLint plugin rules as `hosted/`. + * Their severities and options are replayed from the source config block by block, in ESLint's order. + * + * A rule that can run neither natively nor as a JS plugin must be listed in NOT_PORTED, or config loading + * fails. That way a new rule in eslint-config-expensify gets a decision instead of being dropped silently. + * + * Config and JS plugins load in the same Node process, so the plugin files import the rule lists from here + * and this module runs once per Oxlint invocation. + */ +import migrate from '@oxlint/migrate'; +import {builtinRules} from 'eslint/use-at-your-own-risk'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import eslintConfig from './sourceConfig.mjs'; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify', {paths: [repoRoot]})); + +/** + * Resolves a package the way eslint-config-expensify does, so plugins it depends on load even when they are + * not direct dependencies of Onyx. + * @param {string} request + * @returns {string} + */ +function resolvePackage(request) { + return require.resolve(request, {paths: [expensifyConfigDirectory, repoRoot]}); +} + +// ESLint plugins whose names Oxlint reserves for its native ports. Their rules can only run under `hosted`. +const HOSTABLE_PLUGINS = { + react: 'eslint-plugin-react', + import: 'eslint-plugin-import', + jsdoc: 'eslint-plugin-jsdoc', + '@typescript-eslint': '@typescript-eslint/eslint-plugin', +}; + +// Rules migrate converts to a native rule that behaves differently, hosted to keep ESLint's behavior. +const ALWAYS_HOSTED = new Set([ + // Native import/prefer-default-export ignores `export type {...}`; ESLint's reports it (tests/types.ts). + 'import/prefer-default-export', +]); + +// Type-checked rules that still work as JS plugins once parser services are stubbed (see hosted-rules.mjs). +// Onyx's naming-convention options use no `types` selector, so the type checker is never reached. +const STUBBED_PARSER_SERVICES = new Set(['@typescript-eslint/naming-convention']); + +// Rules ESLint runs that Oxlint does not. +const NOT_PORTED = { + 'no-invalid-this': 'calls sourceCode.getJSDocComment, which the JS plugin bridge lacks (the plugin throws and drops every JS-plugin finding in the file)', + 'react/jsx-uses-react': 'only marks variables as used; Oxlint no-unused-vars understands JSX', + 'react/jsx-uses-vars': 'only marks variables as used; Oxlint no-unused-vars understands JSX', + 'react-hooks/component-hook-factories': 'React Compiler diagnostic category that Oxlint does not implement', + 'react-hooks/config': 'React Compiler diagnostic category that Oxlint does not implement', + 'react-hooks/gating': 'React Compiler diagnostic category that Oxlint does not implement', + '@typescript-eslint/prefer-optional-chain': 'needs type information; the native rule is nursery', + 'rulesdir/boolean-conditional-rendering': 'needs type information; no Oxlint equivalent', +}; + +// Native Oxlint rules configured in place of ESLint rules that cannot run in Oxlint (Oxlint rule: ESLint rule). +// unicorn/prefer-at keeps default options: `checkAllIndexAccess` adds hundreds of reports on tuple access that +// the type-aware ESLint rule skips. +const STAND_INS = { + 'unicorn/prefer-at': 'rulesdir/prefer-at', +}; +const STAND_IN_FOR = Object.fromEntries(Object.entries(STAND_INS).map(([oxlintRule, eslintRule]) => [eslintRule, oxlintRule])); + +// Rules migrate copies with options that Oxlint rejects. +const DROP_OPTIONS = new Set(['react/jsx-no-duplicate-props', 'react/jsx-no-undef']); + +// Matches a glob for TypeScript files, like `**/*.ts`, `**/*.ts?(x)` or `**/*.{js,ts}`. +const TYPESCRIPT_GLOB = /\.(\{[^}]*\b)?[cm]?tsx?\b/; + +const pluginRulesCache = new Map(); + +/** + * Loads the rules of an installed ESLint plugin, so Oxlint runs the same rule code as ESLint. + * @param {string} prefix an ESLint rule prefix from HOSTABLE_PLUGINS, like `react` + * @returns {Record} + */ +function pluginRules(prefix) { + if (!pluginRulesCache.has(prefix)) { + const mod = require(resolvePackage(HOSTABLE_PLUGINS[prefix])); + pluginRulesCache.set(prefix, mod?.rules ?? mod?.default?.rules ?? {}); + } + return pluginRulesCache.get(prefix); +} + +/** + * @param {string} ruleId an ESLint rule id + * @returns {{prefix: string, name: string}} + */ +function splitRuleId(ruleId) { + const slash = ruleId.lastIndexOf('/'); + return slash === -1 ? {prefix: '', name: ruleId} : {prefix: ruleId.slice(0, slash), name: ruleId.slice(slash + 1)}; +} + +/** + * Runs migrate and returns its config plus the rules it skipped. The second, non-type-aware pass finds ESLint + * core rules that migrate swaps for a type-aware typescript/* rule (like dot-notation). tsgolint only checks + * files in the TypeScript program, so those still need the core rule on JS files. + */ +async function runMigrate() { + const skipped = new Set(); + const run = async (typeAware) => { + const skippedHere = new Map(); + const reporter = { + addWarning: () => {}, + getWarnings: () => [], + markSkipped: (rule, category) => skippedHere.set(`${rule}\0${category}`, rule), + removeSkipped: (rule, category) => skippedHere.delete(`${rule}\0${category}`), + getSkippedRulesByCategory: () => ({}), + }; + const config = await migrate(eslintConfig, undefined, {reporter, typeAware, jsPlugins: true}); + for (const rule of skippedHere.values()) { + if (typeAware || builtinRules.has(rule)) { + skipped.add(rule); + } + } + return config; + }; + const config = await run(true); + await run(false); + return {config, skipped}; +} + +const {config: migrated, skipped} = await runMigrate(); + +const coreRuleNames = []; +const hostedRuleIds = []; +for (const ruleId of [...skipped, ...ALWAYS_HOSTED]) { + const {prefix, name} = splitRuleId(ruleId); + if (NOT_PORTED[ruleId] || STAND_IN_FOR[ruleId]) { + continue; + } + if (!prefix && builtinRules.has(name)) { + coreRuleNames.push(name); + continue; + } + const rule = HOSTABLE_PLUGINS[prefix] ? pluginRules(prefix)[name] : undefined; + if (rule && (!rule.meta?.docs?.requiresTypeChecking || STUBBED_PARSER_SERVICES.has(ruleId))) { + hostedRuleIds.push(ruleId); + continue; + } + throw new Error(`Oxlint cannot run ${ruleId} natively or as a JS plugin. Add it to NOT_PORTED or STAND_INS in config/oxlint/derive.mjs.`); +} + +// Hosted rules share the `hosted` namespace, so this maps each one back to its ESLint plugin. +const HOSTED_RULE_ORIGIN = {}; +for (const ruleId of hostedRuleIds) { + const {prefix, name} = splitRuleId(ruleId); + if (HOSTED_RULE_ORIGIN[name]) { + throw new Error(`hosted/${name} would stand for both ${HOSTED_RULE_ORIGIN[name]}/${name} and ${ruleId}`); + } + HOSTED_RULE_ORIGIN[name] = prefix; +} + +/** + * Maps an ESLint rule id to the JS-plugin rule that runs it in Oxlint, if any. + * @param {string} ruleId + * @returns {string | undefined} + */ +function jsPluginRuleFor(ruleId) { + if (coreRuleNames.includes(ruleId)) { + return `core/${ruleId}`; + } + return hostedRuleIds.includes(ruleId) ? `hosted/${splitRuleId(ruleId).name}` : undefined; +} + +/** + * @param {unknown} entry + * @returns {unknown} + */ +const severityOf = (entry) => (Array.isArray(entry) ? entry[0] : entry); + +/** + * Fixes the rules of one migrated config block. + * @param {Record | undefined} rules + * @param {boolean} isTypeScriptBlock whether the block matches TypeScript files + * @returns {Record} + */ +function fixRules(rules = {}, isTypeScriptBlock = false) { + const out = {}; + for (const [rule, entry] of Object.entries(rules)) { + if (ALWAYS_HOSTED.has(rule)) { + continue; + } + if (rule.startsWith('rulesdir/')) { + // eslint-plugin-expensify rules need type information, which Oxlint JS plugins lack. + if (STAND_IN_FOR[rule]) { + out[STAND_IN_FOR[rule]] = severityOf(entry); + } else if (severityOf(entry) !== 'off' && !NOT_PORTED[rule]) { + throw new Error(`${rule} is enabled but has no Oxlint stand-in. Add it to NOT_PORTED or STAND_INS in config/oxlint/derive.mjs.`); + } + continue; + } + if (DROP_OPTIONS.has(rule)) { + out[rule] = severityOf(entry); + continue; + } + if (rule === 'import/no-cycle' && Array.isArray(entry)) { + // migrate copies ESLint's default maxDepth "∞", which Oxlint rejects; Oxlint's default is unlimited too. + const {maxDepth, ...options} = entry[1] ?? {}; + out[rule] = maxDepth === '∞' ? [entry[0], options] : entry; + continue; + } + if (rule === 'no-unused-vars' && isTypeScriptBlock && Array.isArray(entry)) { + // typescript-eslint's no-unused-vars reports values only used as types; Oxlint needs this option for that. + out[rule] = [entry[0], {...entry[1], reportVarsOnlyUsedAsTypes: true}]; + continue; + } + out[rule] = entry; + } + return out; +} + +/** + * Deep-merges the `import/*` settings of every ESLint block, as ESLint does for the files that match them. + * Oxlint has no per-override settings and its own import rules ignore these, but the hosted eslint-plugin-import + * rules read them. `import/parsers` is left out: Oxlint parses every file itself. + * @returns {Record} + */ +function importSettings() { + const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); + const merge = (target, source) => { + for (const [key, value] of Object.entries(source)) { + target[key] = isObject(value) && isObject(target[key]) ? merge({...target[key]}, value) : value; + } + return target; + }; + const settings = {}; + for (const block of eslintConfig) { + const entries = Object.entries(block.settings ?? {}).filter(([key]) => key.startsWith('import/') && key !== 'import/parsers'); + merge(settings, Object.fromEntries(entries)); + } + return settings; +} + +/** + * Collects the jsPlugins migrate put inside overrides, which Oxlint only accepts at the root. + * @returns {Array<{name: string, specifier: string}>} + */ +function hoistedJsPlugins() { + const specifiers = new Set([migrated, ...(migrated.overrides ?? [])].flatMap((block) => block.jsPlugins ?? [])); + // eslint-plugin-expensify (rulesdir) rules are handled by fixRules. + specifiers.delete('eslint-plugin-rulesdir'); + return [...specifiers].map((specifier) => ({ + name: specifier.replace(/^eslint-plugin-/, ''), + specifier: resolvePackage(specifier), + })); +} + +/** + * Replays the source config for the rules that run as JS plugins, in ESLint block order. + * @returns {{rules: Record, overrides: Array<{files: string[], rules: Record}>}} + */ +function replayJsPluginRules() { + const rules = {}; + const overrides = []; + const scopedRules = new Set(); + for (const block of eslintConfig) { + const hits = Object.entries(block.rules ?? {}).flatMap(([ruleId, entry]) => { + const target = jsPluginRuleFor(ruleId); + return target ? [[target, entry]] : []; + }); + if (!hits.length) { + continue; + } + if (block.ignores) { + throw new Error('ESLint block with both rules and ignores; replaying it is not supported'); + } + if (!block.files) { + const clash = hits.filter(([rule]) => scopedRules.has(rule)).map(([rule]) => rule); + if (clash.length) { + throw new Error(`Unscoped ESLint block after a scoped one sets ${clash.join(', ')}; Oxlint would apply them in a different order`); + } + Object.assign(rules, Object.fromEntries(hits)); + continue; + } + if (block.files.some(Array.isArray)) { + throw new Error('ESLint `files` with nested arrays (AND patterns) cannot be expressed in Oxlint'); + } + hits.forEach(([rule]) => scopedRules.add(rule)); + overrides.push({files: block.files, rules: Object.fromEntries(hits)}); + } + return {rules, overrides}; +} + +const replayed = replayJsPluginRules(); + +/** @type {import('oxlint').OxlintConfig} */ +const oxlintConfig = { + plugins: migrated.plugins, + jsPlugins: [ + {name: 'core', specifier: fileURLToPath(new URL('plugins/core-rules.mjs', import.meta.url))}, + {name: 'hosted', specifier: fileURLToPath(new URL('plugins/hosted-rules.mjs', import.meta.url))}, + ...hoistedJsPlugins(), + ], + categories: migrated.categories, + options: {typeAware: true}, + env: migrated.env, + globals: migrated.globals, + settings: { + ...migrated.settings, + ...importSettings(), + // ESLint detects the version; Oxlint needs it spelled out. + react: {...migrated.settings?.react, version: require(resolvePackage('react/package.json')).version}, + }, + ignorePatterns: migrated.ignorePatterns, + rules: {...fixRules(migrated.rules), ...replayed.rules}, + overrides: [ + ...(migrated.overrides ?? []).map(({files, plugins, env, globals, rules}) => ({ + files, + ...(plugins ? {plugins} : {}), + ...(env ? {env} : {}), + ...(globals ? {globals} : {}), + ...(rules + ? { + rules: fixRules( + rules, + files.some((file) => TYPESCRIPT_GLOB.test(file)), + ), + } + : {}), + })), + ...replayed.overrides, + ], +}; + +export {HOSTED_RULE_ORIGIN, STAND_INS, STUBBED_PARSER_SERVICES, coreRuleNames, hostedRuleIds, oxlintConfig, pluginRules, splitRuleId}; diff --git a/config/oxlint/plugins/core-rules.mjs b/config/oxlint/plugins/core-rules.mjs index d9b381cb7..0a5389301 100644 --- a/config/oxlint/plugins/core-rules.mjs +++ b/config/oxlint/plugins/core-rules.mjs @@ -1,40 +1,22 @@ /** * ESLint core rules that Oxlint has no native (non-nursery) version of. They run inside Oxlint as a JS * plugin under the `core/` prefix, using ESLint's own rule code, and respect `eslint-disable` comments - * written with the ESLint rule id. Which files each one runs on is set in oxlint.config.mts, copying ESLint. + * written with the ESLint rule id. config/oxlint/derive.mjs picks the rules and the files each one runs on. * Adapted from Expensify/App config/oxlint/plugins/core-rules.mjs. * - * no-invalid-this is left out, as in App: it calls sourceCode.getJSDocComment, which Oxlint's plugin - * bridge does not support. Verified on oxlint 1.90.0: enabling it makes the JS plugin throw on any file with - * a function, which drops every JS-plugin finding for that file. + * no-invalid-this is left out (see NOT_PORTED in derive.mjs). */ import {builtinRules} from 'eslint/use-at-your-own-risk'; +import {coreRuleNames} from '../derive.mjs'; import {withEslintDirectiveIDsFor} from '../eslintDirectives.mjs'; -const NAMES = [ - 'no-restricted-syntax', - 'lines-between-class-members', - 'no-octal', - 'no-octal-escape', - 'no-undef-init', - 'strict', - 'no-dupe-args', - 'no-return-await', - 'no-new-symbol', - 'no-new-object', - 'no-unreachable-loop', - 'no-restricted-exports', - 'no-undef', - 'dot-notation', -]; - const plugin = { meta: { name: 'core', version: '0.0.1', }, - rules: withEslintDirectiveIDsFor(Object.fromEntries(NAMES.map((name) => [name, builtinRules.get(name)])), (name) => name), + rules: withEslintDirectiveIDsFor(Object.fromEntries(coreRuleNames.map((name) => [name, builtinRules.get(name)])), (name) => name), }; export default plugin; diff --git a/config/oxlint/plugins/expensify-rules.mjs b/config/oxlint/plugins/expensify-rules.mjs deleted file mode 100644 index a3ab6c53a..000000000 --- a/config/oxlint/plugins/expensify-rules.mjs +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Loads every rule from eslint-config-expensify's eslint-plugin-expensify into Oxlint under the `rulesdir` - * name ESLint uses, so `rulesdir/` ids and `eslint-disable` comments stay the same. - * Adapted from Expensify/App config/oxlint/plugins/expensify-rules.mjs. - * - * Rules that call `ESLintUtils.getParserServices` (type-aware rules such as `prefer-at` and - * `boolean-conditional-rendering`) cannot run here: Oxlint JS plugins get no TypeScript program. - * They are detected by source, collected in TYPE_AWARE_RULES and not registered; `oxlint.config.mts` uses a - * native stand-in where one exists. - */ -import fs from 'node:fs'; -import {createRequire} from 'node:module'; -import path from 'node:path'; -import {pathToFileURL} from 'node:url'; - -const require = createRequire(import.meta.url); -const RULES_DIR = path.join(path.dirname(require.resolve('eslint-config-expensify')), 'eslint-plugin-expensify'); - -const HELPER_FILES = new Set(['CONST.js']); - -/** Rules whose source needs TypeScript parser services. Kept in sync by hand with eslint-plugin-expensify. */ -const TYPE_AWARE_RULES = new Set(); - -const rules = {}; -for (const file of fs.readdirSync(RULES_DIR)) { - if (!file.endsWith('.js') || HELPER_FILES.has(file)) { - continue; - } - const name = file.replace(/\.js$/, ''); - const source = fs.readFileSync(path.join(RULES_DIR, file), 'utf8'); - if (source.includes('getParserServices')) { - TYPE_AWARE_RULES.add(name); - continue; - } - const mod = await import(pathToFileURL(path.join(RULES_DIR, file)).href); - rules[name] = mod.default ?? {meta: mod.meta, create: mod.create}; -} - -const plugin = { - meta: { - name: 'rulesdir', - version: '0.0.1', - }, - rules, -}; - -export {TYPE_AWARE_RULES}; -export default plugin; diff --git a/config/oxlint/plugins/hosted-rules.mjs b/config/oxlint/plugins/hosted-rules.mjs index 398acb522..30afee4ef 100644 --- a/config/oxlint/plugins/hosted-rules.mjs +++ b/config/oxlint/plugins/hosted-rules.mjs @@ -1,37 +1,15 @@ /** * Runs rules from ESLint plugins (typescript-eslint, import, jsdoc, react) inside Oxlint, using the plugins' * own code, for rules Oxlint has no native version of. Oxlint reserves those plugin names for its own - * ports, so all of them load under `hosted`; config/oxlint/ruleNames.mjs maps them back to ESLint ids. - * Adapted from Expensify/App config/oxlint/plugins/hosted-rules.mjs (React Compiler gating removed). + * ports, so all of them load under `hosted`; HOSTED_RULE_ORIGIN in config/oxlint/derive.mjs maps them back. + * config/oxlint/derive.mjs picks the rules. Adapted from Expensify/App config/oxlint/plugins/hosted-rules.mjs. */ -import {createRequire} from 'node:module'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; +import {STUBBED_PARSER_SERVICES, hostedRuleIds, pluginRules, splitRuleId} from '../derive.mjs'; +import {withEslintDirectiveIDs} from '../eslintDirectives.mjs'; -import {withEslintDirectiveIDsFor} from '../eslintDirectives.mjs'; -import {hostedRuleNames} from '../ruleNames.mjs'; - -const require = createRequire(import.meta.url); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); - -/** - * Loads the rules of an installed ESLint plugin, so Oxlint runs the same rule code as ESLint. - * @param {string} packageName - * @returns {Record} - */ -function rulesOf(packageName) { - const mod = require(require.resolve(packageName, {paths: [repoRoot]})); - 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 typescriptEslint = rulesOf('@typescript-eslint/eslint-plugin'); - -// naming-convention asks for parser services, which Oxlint JS plugins lack. getParserServices(context, true) +// Type-checked rules such as naming-convention ask for parser services, which Oxlint 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. Onyx's naming-convention options use no `types` selector, so the type checker is never hit. +// both are stubbed. Only rules listed in STUBBED_PARSER_SERVICES get this, because they never reach the type checker. const STUB_PARSER_SERVICES = { esTreeNodeToTSNodeMap: new Map(), tsNodeToESTreeNodeMap: new Map(), @@ -62,34 +40,19 @@ function withStubbedParserServices(rule) { }; } -/** - * Picks `names` from an ESLint plugin and makes `eslint-disable` comments that use the ESLint id - * (`/`) apply to them. - * @param {Record} rules - * @param {string} eslintPrefix - * @returns {Record} - */ -function hostRules(rules, eslintPrefix) { - const names = hostedRuleNames(eslintPrefix); - for (const name of names) { - if (!rules[name]) { - throw new Error(`${eslintPrefix}/${name} not found in the installed ESLint plugin`); - } - } - return withEslintDirectiveIDsFor(Object.fromEntries(names.map((name) => [name, rules[name]])), (name) => `${eslintPrefix}/${name}`); -} - const plugin = { meta: { name: 'hosted', version: '0.0.1', }, - rules: { - ...hostRules(react, 'react'), - ...hostRules(importPlugin, 'import'), - ...hostRules(jsdoc, 'jsdoc'), - ...hostRules({'naming-convention': withStubbedParserServices(typescriptEslint['naming-convention'])}, '@typescript-eslint'), - }, + rules: Object.fromEntries( + hostedRuleIds.map((ruleId) => { + const {prefix, name} = splitRuleId(ruleId); + const rule = pluginRules(prefix)[name]; + // Makes `eslint-disable` comments that use the ESLint id apply to the hosted rule. + return [name, withEslintDirectiveIDs(STUBBED_PARSER_SERVICES.has(ruleId) ? withStubbedParserServices(rule) : rule, ruleId)]; + }), + ), }; export default plugin; diff --git a/config/oxlint/ruleNames.mjs b/config/oxlint/ruleNames.mjs index dd2cb8503..e35d90dff 100644 --- a/config/oxlint/ruleNames.mjs +++ b/config/oxlint/ruleNames.mjs @@ -3,45 +3,13 @@ * reported, compared and gated under ESLint's rule ids. Adapted from Expensify/App config/oxlint/ruleNames.mjs. * Plain `.mjs` because Oxlint JS plugins cannot load TypeScript. */ +import {HOSTED_RULE_ORIGIN, STAND_INS} from './derive.mjs'; + const OXLINT_RULE_RENAMES = { 'no-object-constructor': 'no-new-object', 'no-new-native-nonconstructor': 'no-new-symbol', }; -// Oxlint reserves its native plugin prefixes (`react`, `import`, `jsdoc`, `@typescript-eslint`), so rules -// hosted from those ESLint plugins load under `hosted`. This maps each one back to its ESLint plugin. -const HOSTED_RULE_ORIGIN = { - 'naming-convention': '@typescript-eslint', - 'no-extraneous-dependencies': 'import', - 'no-useless-path-segments': 'import', - 'no-import-module-exports': 'import', - 'no-relative-packages': 'import', - export: 'import', - named: 'import', - order: 'import', - // Native import/prefer-default-export ignores `export type {...}`; ESLint's reports it (tests/types.ts). - 'prefer-default-export': 'import', - 'no-types': 'jsdoc', - 'check-param-names': 'jsdoc', - 'check-types': 'jsdoc', - 'jsx-no-bind': 'react', - 'no-typos': 'react', - 'no-arrow-function-lifecycle': 'react', - 'no-invalid-html-attribute': 'react', - 'require-render-return': 'react', - 'forbid-prop-types': 'react', - 'forbid-foreign-prop-types': 'react', - 'default-props-match-prop-types': 'react', - 'no-deprecated': 'react', - 'prefer-stateless-function': 'react', - 'sort-comp': 'react', - 'no-unused-state': 'react', - 'no-access-state-in-setstate': 'react', - 'static-property-placement': 'react', - 'prefer-exact-props': 'react', - 'no-unused-class-component-methods': 'react', -}; - // Oxlint native plugin names that differ from the ESLint plugin prefix. const NATIVE_PLUGIN_PREFIX = { typescript: '@typescript-eslint', @@ -89,15 +57,6 @@ const TS_FILE = /\.(ts|tsx|mts|cts)$/; const OXLINT_DIAGNOSTIC_CODE = /^([\w@/.-]+)\((.+)\)$/; -/** - * Lists the hosted rules copied from one ESLint plugin. - * @param {string} eslintPrefix an ESLint plugin name, like `react` - * @returns {string[]} - */ -function hostedRuleNames(eslintPrefix) { - return Object.keys(HOSTED_RULE_ORIGIN).filter((name) => HOSTED_RULE_ORIGIN[name] === eslintPrefix); -} - /** * Converts an Oxlint diagnostic code, like `typescript(no-explicit-any)`, to ESLint's rule id, like * `@typescript-eslint/no-explicit-any`. A code that does not look like `plugin(rule)` comes back unchanged. @@ -131,43 +90,4 @@ function oxlintCodeToESLintRuleID(code, filename = '') { return `${NATIVE_PLUGIN_PREFIX[plugin] ?? plugin}/${rule}`; } -// Native Oxlint rules configured in place of ESLint rules that cannot run in Oxlint. -const STAND_INS = { - 'unicorn/prefer-at': 'rulesdir/prefer-at', -}; - -/** - * Maps a rule key as written in oxlint.config.mts (like `typescript/no-explicit-any`, `hosted/naming-convention` - * or `no-unused-vars`) to the ESLint rule ids it stands for. A native rule that ESLint runs as both a core rule - * and a typescript-eslint extension maps to both. - * @param {string} key - * @returns {string[]} - */ -function oxlintConfigRuleToESLintRuleIDs(key) { - if (STAND_INS[key]) { - return [STAND_INS[key]]; - } - const slash = key.indexOf('/'); - if (slash === -1) { - const name = OXLINT_RULE_RENAMES[key] ?? key; - return TS_EXTENSION_RULES.has(key) ? [name, `@typescript-eslint/${key}`] : [name]; - } - const plugin = key.slice(0, slash); - const name = key.slice(slash + 1); - if (plugin === 'core') { - return [name]; - } - if (plugin === 'hosted') { - return [`${HOSTED_RULE_ORIGIN[name]}/${name}`]; - } - if (plugin === 'typescript') { - return [`@typescript-eslint/${name}`]; - } - if (plugin === 'react' && REACT_HOOKS_RULES.has(name)) { - return [`react-hooks/${name}`]; - } - return [key]; -} - -export {STAND_INS, oxlintConfigRuleToESLintRuleIDs}; -export {HOSTED_RULE_ORIGIN, OXLINT_RULE_RENAMES, REACT_HOOKS_RULES, TS_EXTENSION_RULES, hostedRuleNames, oxlintCodeToESLintRuleID}; +export {HOSTED_RULE_ORIGIN, OXLINT_RULE_RENAMES, REACT_HOOKS_RULES, STAND_INS, TS_EXTENSION_RULES, oxlintCodeToESLintRuleID}; diff --git a/config/oxlint/sourceConfig.mjs b/config/oxlint/sourceConfig.mjs new file mode 100644 index 000000000..80ee88de8 --- /dev/null +++ b/config/oxlint/sourceConfig.mjs @@ -0,0 +1,150 @@ +/** + * Source of the Oxlint config: the eslint-config-expensify presets plus Onyx's own blocks, in ESLint flat-config + * format. config/oxlint/derive.mjs converts it with @oxlint/migrate every time Oxlint loads, so bumping + * eslint-config-expensify updates Oxlint without editing any rule by hand. Onyx-specific changes go here. + * + * It does not need eslint.config.mjs. Until ESLint is removed, keep the Onyx blocks below in sync with it + * (the lint-parity comparison shows any drift). + */ +import browserConfig from 'eslint-config-expensify/browser'; +import jestConfig from 'eslint-config-expensify/jest'; +import reactConfig from 'eslint-config-expensify/react'; +import scriptsConfig from 'eslint-config-expensify/scripts'; +import tsExpensifyConfig from 'eslint-config-expensify/typescript'; +import prettierConfig from 'eslint-config-prettier'; +import {defineConfig, globalIgnores} from 'eslint/config'; +import globals from 'globals'; + +import {downgradeDebtRules} from '../lintDebt.mjs'; + +const configs = defineConfig([ + globalIgnores([ + 'dist/**', + 'node_modules/**', + '**/*.d.ts', + '**/*.config.js', + '**/*.config.cjs', + 'tests/types/**/*.ts', + 'cpp/**', + 'bench-results/**', + '.github/**', + 'lint-parity/**', + 'oxlint.config.mts', + 'config/oxlint/**', + ]), + ...browserConfig, + ...reactConfig, + ...tsExpensifyConfig, + ...jestConfig.map((config) => ({ + ...config, + files: ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts'], + })), + ...scriptsConfig, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', + }, + }, + }, + rules: { + 'react/prop-types': 'off', + 'react/require-default-props': 'off', + }, + }, + { + files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], + languageOptions: { + globals: globals.node, + }, + }, + // Flat config replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript + // and adds objectLiteralProperty exceptions for Onyx key strings (underscores) and numeric collection IDs. + { + files: ['tests/**/*'], + rules: { + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: ['variable', 'property'], + format: null, + filter: { + regex: '^__esModule$', + match: true, + }, + }, + { + selector: ['variable', 'property'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + filter: { + regex: '^private_[a-z][a-zA-Z0-9]*$', + match: false, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '_', + match: true, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '^[0-9]+$', + match: true, + }, + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + { + selector: ['typeLike', 'enumMember'], + format: ['PascalCase'], + }, + { + selector: ['parameter', 'method'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + ], + }, + }, + { + files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], + rules: { + 'import/extensions': 'off', + }, + }, + { + files: ['lib/storage/providers/MemoryOnlyProvider.ts'], + languageOptions: { + globals: { + ...globals.jest, + }, + }, + }, + { + files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], + rules: { + '@typescript-eslint/dot-notation': 'off', + }, + }, + { + files: ['tests/perf-test/**/*'], + rules: { + 'rulesdir/prefer-at': 'off', + }, + }, + prettierConfig, +]); + +// Count-threshold gate: rules with existing violations report as warnings, and `npm run lint:oxlint` fails above +// the warning count it passes to --max-warnings. See config/lintDebt.mjs. +export default configs.map((entry) => (entry.rules ? {...entry, rules: downgradeDebtRules(entry.rules)} : entry)); diff --git a/oxlint.config.mts b/oxlint.config.mts index 0c23e2deb..529df9ad1 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -1,1439 +1,22 @@ /** - * Oxlint config for react-native-onyx, kept in parity with eslint.config.mjs (eslint-config-expensify 4.x). - * - * Starting point: `npx @oxlint/migrate@1.90.0 eslint.config.mjs --type-aware` (raw output kept in - * lint-parity/oxlintrc.migrate-raw.json), turned into this file by lint-parity/scripts/generateOxlintConfig.mjs - * and then edited by hand. See lint-parity/README.md for parity results and known gaps. + * Oxlint config for react-native-onyx. No rule is written by hand: config/oxlint/derive.mjs converts the + * eslint-config-expensify presets plus Onyx's own blocks (config/oxlint/sourceConfig.mjs) with @oxlint/migrate + * each time Oxlint loads, so bumping eslint-config-expensify updates Oxlint. It does not read eslint.config.mjs. * * Rules Oxlint has no native version of run through JS plugins (see config/oxlint/plugins): * - core/*: ESLint core rules, with ESLint's own code. * - hosted/*: rules from ESLint plugins (typescript-eslint naming-convention, import, jsdoc, react). - * - rulesdir/*: eslint-plugin-expensify rules that need no type information. * - es/*: eslint-plugin-es, loaded as is (JS files only). * - * Type-aware eslint-plugin-expensify rules cannot run in a JS plugin (Oxlint gives JS plugins no TypeScript program): - * - rulesdir/prefer-at: unicorn/prefer-at stands in for it and covers fewer cases. - * - rulesdir/boolean-conditional-rendering: dropped, no Oxlint equivalent. + * Rules Oxlint cannot run at all are listed with the reason in NOT_PORTED and STAND_INS in derive.mjs. + * Severities already include the warning gate in config/lintDebt.mjs. */ import {defineConfig} from 'oxlint'; import type {OxlintConfig} from 'oxlint'; -import {downgradeDebtRules} from './config/lintDebt.mjs'; -import {oxlintConfigRuleToESLintRuleIDs} from './config/oxlint/ruleNames.mjs'; - -const config = defineConfig({ - plugins: ['typescript', 'import', 'jsx-a11y', 'unicorn', 'react'], - jsPlugins: [ - { - name: 'rulesdir', - specifier: './config/oxlint/plugins/expensify-rules.mjs', - }, - { - name: 'core', - specifier: './config/oxlint/plugins/core-rules.mjs', - }, - { - name: 'hosted', - specifier: './config/oxlint/plugins/hosted-rules.mjs', - }, - { - name: 'es', - specifier: 'eslint-plugin-es', - }, - ], - categories: { - correctness: 'off', - }, - options: { - typeAware: true, - }, - env: { - builtin: true, - es2026: true, - browser: true, - }, - settings: { - propWrapperFunctions: ['forbidExtraProps', 'exact', 'Object.freeze'], - react: { - version: '18.2.0', - }, - 'import/resolver': { - node: { - extensions: ['.js', '.jsx', '.json'], - }, - typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', - }, - }, - 'import/extensions': ['.js', '.mjs', '.jsx', '.ts', '.tsx', '.cts', '.mts'], - 'import/core-modules': [], - 'import/ignore': ['node_modules', '\\.(coffee|scss|css|less|hbs|svg|json)$'], - 'import/external-module-folders': ['node_modules', 'node_modules/@types'], - }, - ignorePatterns: [ - 'dist/**', - 'node_modules/**', - '**/*.d.ts', - '**/*.config.js', - '**/*.config.cjs', - 'tests/types/**/*.ts', - 'cpp/**', - 'bench-results/**', - '.github/**', - 'lint-parity/**', - 'oxlint.config.mts', - 'config/oxlint/**', - ], - rules: { - 'array-callback-return': [ - 'error', - { - allowImplicit: true, - }, - ], - 'block-scoped-var': 'error', - 'default-case': [ - 'error', - { - commentPattern: '^no default$', - }, - ], - 'default-case-last': 'error', - 'default-param-last': 'error', - eqeqeq: [ - 'error', - 'always', - { - null: 'ignore', - }, - ], - 'grouped-accessor-pairs': 'error', - 'guard-for-in': 'error', - 'max-classes-per-file': ['error', 1], - 'no-caller': 'error', - 'no-case-declarations': 'error', - 'no-constructor-return': 'error', - 'no-else-return': [ - 'error', - { - allowElseIf: false, - }, - ], - 'no-empty-function': [ - 'error', - { - allow: ['arrowFunctions', 'functions', 'methods'], - }, - ], - 'no-empty-pattern': 'error', - 'no-eval': 'error', - 'no-extend-native': 'error', - 'no-extra-bind': 'error', - 'no-extra-label': 'error', - 'no-fallthrough': 'error', - 'no-global-assign': [ - 'error', - { - exceptions: [], - }, - ], - 'no-implied-eval': 'error', - 'no-iterator': 'error', - 'no-labels': [ - 'error', - { - allowLoop: false, - allowSwitch: false, - }, - ], - 'no-lone-blocks': 'error', - 'no-loop-func': 'error', - 'no-multi-str': 'error', - 'no-new': 'error', - 'no-new-func': 'error', - 'no-new-wrappers': 'error', - 'no-nonoctal-decimal-escape': 'error', - 'no-param-reassign': [ - 'error', - { - props: true, - ignorePropertyModificationsFor: ['acc', 'accumulator', 'e', 'ctx', 'context', 'req', 'request', 'res', 'response', '$scope', 'staticContext'], - }, - ], - 'no-proto': 'error', - 'no-restricted-properties': [ - 'error', - { - object: 'arguments', - property: 'callee', - message: 'arguments.callee is deprecated', - }, - { - object: 'global', - property: 'isFinite', - message: 'Please use Number.isFinite instead', - }, - { - object: 'self', - property: 'isFinite', - message: 'Please use Number.isFinite instead', - }, - { - object: 'window', - property: 'isFinite', - message: 'Please use Number.isFinite instead', - }, - { - object: 'global', - property: 'isNaN', - message: 'Please use Number.isNaN instead', - }, - { - object: 'self', - property: 'isNaN', - message: 'Please use Number.isNaN instead', - }, - { - object: 'window', - property: 'isNaN', - message: 'Please use Number.isNaN instead', - }, - { - property: '__defineGetter__', - message: 'Please use Object.defineProperty instead.', - }, - { - property: '__defineSetter__', - message: 'Please use Object.defineProperty instead.', - }, - { - object: 'Math', - property: 'pow', - message: 'Use the exponentiation operator (**) instead.', - }, - ], - 'no-script-url': 'error', - 'no-self-assign': [ - 'error', - { - props: true, - }, - ], - 'no-self-compare': 'error', - 'no-sequences': 'error', - 'no-throw-literal': 'error', - 'no-unused-expressions': [ - 'error', - { - allowShortCircuit: false, - allowTernary: false, - allowTaggedTemplates: false, - }, - ], - 'no-unused-labels': 'error', - 'no-useless-catch': 'error', - 'no-useless-concat': 'error', - 'no-useless-escape': 'error', - 'no-useless-return': 'error', - 'no-void': 'error', - 'no-with': 'error', - 'prefer-promise-reject-errors': [ - 'error', - { - allowEmptyReject: true, - }, - ], - 'prefer-regex-literals': [ - 'error', - { - disallowRedundantWrapping: true, - }, - ], - radix: 'error', - yoda: 'error', - 'for-direction': 'error', - 'getter-return': [ - 'error', - { - allowImplicit: true, - }, - ], - 'no-async-promise-executor': 'error', - 'no-await-in-loop': 'error', - 'no-compare-neg-zero': 'error', - 'no-cond-assign': ['error', 'always'], - 'no-console': [ - 'error', - { - allow: ['debug', 'error'], - }, - ], - 'no-constant-condition': 'error', - 'no-control-regex': 'error', - 'no-debugger': 'error', - 'no-dupe-else-if': 'error', - 'no-dupe-keys': 'error', - 'no-duplicate-case': 'error', - 'no-empty': 'error', - 'no-empty-character-class': 'error', - 'no-ex-assign': 'error', - 'no-extra-boolean-cast': 'error', - 'no-func-assign': 'error', - 'no-import-assign': 'error', - 'no-inner-declarations': 'error', - 'no-invalid-regexp': 'error', - 'no-irregular-whitespace': 'error', - 'no-loss-of-precision': 'error', - 'no-misleading-character-class': 'error', - 'no-obj-calls': 'error', - 'no-promise-executor-return': 'error', - 'no-prototype-builtins': 'error', - 'no-regex-spaces': 'error', - 'no-setter-return': 'error', - 'no-sparse-arrays': 'error', - 'no-template-curly-in-string': 'error', - 'no-unreachable': 'error', - 'no-unsafe-finally': 'error', - 'no-unsafe-negation': 'error', - 'no-unsafe-optional-chaining': [ - 'error', - { - disallowArithmeticOperators: true, - }, - ], - 'no-useless-backreference': 'error', - 'use-isnan': 'error', - 'valid-typeof': [ - 'error', - { - requireStringLiterals: true, - }, - ], - 'arrow-body-style': [ - 'error', - 'as-needed', - { - requireReturnForObjectLiteral: false, - }, - ], - 'constructor-super': 'error', - 'no-class-assign': 'error', - 'no-const-assign': 'error', - 'no-dupe-class-members': 'error', - 'no-this-before-super': 'error', - 'no-useless-computed-key': 'error', - 'no-useless-constructor': 'error', - 'no-useless-rename': [ - 'error', - { - ignoreDestructuring: false, - ignoreImport: false, - ignoreExport: false, - }, - ], - 'no-var': 'error', - 'object-shorthand': [ - 'error', - 'always', - { - ignoreConstructors: false, - avoidQuotes: true, - }, - ], - 'prefer-arrow-callback': [ - 'error', - { - allowNamedFunctions: false, - allowUnboundThis: true, - }, - ], - 'prefer-const': [ - 'error', - { - destructuring: 'any', - ignoreReadBeforeAssign: true, - }, - ], - 'prefer-numeric-literals': 'error', - 'prefer-rest-params': 'error', - 'prefer-spread': 'error', - 'prefer-template': 'error', - 'require-yield': 'error', - 'symbol-description': 'error', - 'import/no-named-as-default': 'error', - 'import/no-named-as-default-member': 'error', - 'import/no-mutable-exports': 'error', - 'import/no-amd': 'error', - 'import/first': 'error', - 'import/no-duplicates': 'error', - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'never', - mjs: 'never', - jsx: 'never', - ts: 'never', - tsx: 'never', - cts: 'never', - mts: 'never', - }, - ], - 'import/newline-after-import': 'error', - 'import/no-absolute-path': 'error', - 'import/no-webpack-loader-syntax': 'error', - 'import/no-named-default': 'error', - 'import/no-self-import': 'error', - 'import/no-cycle': 'error', - 'no-array-constructor': 'error', - 'no-bitwise': 'error', - 'no-lonely-if': 'error', - 'no-multi-assign': ['error'], - 'no-nested-ternary': 'error', - 'no-underscore-dangle': [ - 'error', - { - allow: ['__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'], - allowAfterThis: false, - allowAfterSuper: false, - enforceInMethodNames: true, - }, - ], - 'no-unneeded-ternary': [ - 'error', - { - defaultAssignment: false, - }, - ], - 'one-var': ['error', 'never'], - 'operator-assignment': ['error', 'always'], - 'prefer-exponentiation-operator': 'error', - 'prefer-object-spread': 'error', - 'unicorn/prefer-set-has': 'error', - 'unicorn/no-array-for-each': 'error', - 'unicorn/prefer-array-find': 'error', - 'unicorn/prefer-set-size': 'error', - 'unicorn/prefer-string-replace-all': 'error', - 'no-delete-var': 'error', - 'no-label-var': 'error', - 'no-restricted-globals': [ - 'error', - { - name: 'isFinite', - message: 'Use Number.isFinite instead https://github.com/airbnb/javascript#standard-library--isfinite', - }, - { - name: 'isNaN', - message: 'Use Number.isNaN instead https://github.com/airbnb/javascript#standard-library--isnan', - }, - 'addEventListener', - 'blur', - 'close', - 'closed', - 'confirm', - 'defaultStatus', - 'defaultstatus', - 'event', - 'external', - 'find', - 'focus', - 'frameElement', - 'frames', - 'history', - 'innerHeight', - 'innerWidth', - 'length', - 'location', - 'locationbar', - 'menubar', - 'moveBy', - 'moveTo', - 'name', - 'onblur', - 'onerror', - 'onfocus', - 'onload', - 'onresize', - 'onunload', - 'open', - 'opener', - 'opera', - 'outerHeight', - 'outerWidth', - 'pageXOffset', - 'pageYOffset', - 'parent', - 'print', - 'removeEventListener', - 'resizeBy', - 'resizeTo', - 'screen', - 'screenLeft', - 'screenTop', - 'screenX', - 'screenY', - 'scroll', - 'scrollbars', - 'scrollBy', - 'scrollTo', - 'scrollX', - 'scrollY', - 'self', - 'status', - 'statusbar', - 'stop', - 'toolbar', - 'top', - ], - 'no-shadow': 'error', - 'no-shadow-restricted-names': 'error', - 'no-unused-vars': [ - 'error', - { - vars: 'all', - args: 'after-used', - ignoreRestSiblings: true, - }, - ], - 'no-use-before-define': [ - 'error', - { - functions: true, - classes: true, - variables: true, - }, - ], - 'react/jsx-boolean-value': [ - 'error', - 'never', - { - always: [], - }, - ], - 'react/jsx-no-duplicate-props': 'error', - 'react/jsx-no-undef': 'error', - 'react/jsx-pascal-case': [ - 'error', - { - allowAllCaps: true, - ignore: [], - }, - ], - 'react/no-danger': 'error', - 'react/no-did-update-set-state': 'error', - 'react/no-will-update-set-state': 'error', - 'react/no-is-mounted': 'error', - 'react/no-string-refs': 'error', - 'react/no-unknown-property': 'error', - 'react/prefer-es6-class': ['error', 'always'], - 'react/self-closing-comp': 'error', - 'react/jsx-no-target-blank': [ - 'error', - { - enforceDynamicLinks: 'always', - }, - ], - 'react/jsx-filename-extension': [ - 'warn', - { - extensions: ['.js', '.jsx', '.tsx'], - }, - ], - 'react/jsx-no-comment-textnodes': 'error', - 'react/no-render-return-value': 'error', - 'react/no-find-dom-node': 'error', - 'react/no-array-index-key': 'error', - 'react/void-dom-elements-no-children': 'error', - 'react/no-redundant-should-component-update': 'error', - 'react/button-has-type': [ - 'error', - { - button: true, - submit: true, - reset: false, - }, - ], - 'react/no-this-in-sfc': 'error', - 'react/no-unsafe': [ - 'error', - { - checkAliases: true, - }, - ], - 'react/jsx-fragments': ['error', 'syntax'], - 'react/state-in-constructor': ['error', 'always'], - 'react/jsx-no-script-url': [ - 'error', - [ - { - name: 'Link', - props: ['to'], - }, - ], - ], - 'react/jsx-no-useless-fragment': 'error', - 'react/function-component-definition': [ - 'error', - { - namedComponents: 'function-declaration', - unnamedComponents: 'arrow-function', - }, - ], - 'react/jsx-no-constructed-context-values': 'error', - 'react/no-unstable-nested-components': 'error', - 'react/no-namespace': 'error', - 'react/style-prop-object': 'error', - 'react/no-unescaped-entities': 'error', - 'react/no-children-prop': 'error', - 'jsx-a11y/alt-text': [ - 'error', - { - elements: ['img', 'object', 'area', 'input[type="image"]'], - img: [], - object: [], - area: [], - 'input[type="image"]': [], - }, - ], - 'jsx-a11y/anchor-has-content': [ - 'error', - { - components: [], - }, - ], - 'jsx-a11y/anchor-is-valid': [ - 'error', - { - components: ['Link'], - specialLink: ['to'], - aspects: ['noHref', 'invalidHref', 'preferButton'], - }, - ], - 'jsx-a11y/aria-activedescendant-has-tabindex': 'error', - 'jsx-a11y/aria-props': 'error', - 'jsx-a11y/aria-proptypes': 'error', - 'jsx-a11y/aria-role': [ - 'error', - { - ignoreNonDOM: false, - }, - ], - 'jsx-a11y/aria-unsupported-elements': 'error', - 'jsx-a11y/control-has-associated-label': [ - 'error', - { - labelAttributes: ['label'], - controlComponents: [], - ignoreElements: ['audio', 'canvas', 'embed', 'input', 'textarea', 'tr', 'video'], - ignoreRoles: ['grid', 'listbox', 'menu', 'menubar', 'radiogroup', 'row', 'tablist', 'toolbar', 'tree', 'treegrid'], - depth: 5, - }, - ], - 'jsx-a11y/heading-has-content': [ - 'error', - { - components: [''], - }, - ], - 'jsx-a11y/html-has-lang': 'error', - 'jsx-a11y/iframe-has-title': 'error', - 'jsx-a11y/img-redundant-alt': 'error', - 'jsx-a11y/interactive-supports-focus': 'error', - 'jsx-a11y/label-has-associated-control': [ - 'error', - { - labelComponents: [], - labelAttributes: [], - controlComponents: [], - assert: 'either', - depth: 25, - }, - ], - 'jsx-a11y/lang': 'error', - 'jsx-a11y/media-has-caption': [ - 'error', - { - audio: [], - video: [], - track: [], - }, - ], - 'jsx-a11y/mouse-events-have-key-events': 'error', - 'jsx-a11y/no-access-key': 'error', - 'jsx-a11y/no-autofocus': [ - 'error', - { - ignoreNonDOM: true, - }, - ], - 'jsx-a11y/no-distracting-elements': [ - 'error', - { - elements: ['marquee', 'blink'], - }, - ], - 'jsx-a11y/no-interactive-element-to-noninteractive-role': [ - 'error', - { - tr: ['none', 'presentation'], - }, - ], - 'jsx-a11y/no-noninteractive-element-interactions': [ - 'error', - { - handlers: ['onClick', 'onMouseDown', 'onMouseUp', 'onKeyPress', 'onKeyDown', 'onKeyUp'], - }, - ], - 'jsx-a11y/no-noninteractive-element-to-interactive-role': [ - 'error', - { - ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], - ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'], - li: ['menuitem', 'option', 'row', 'tab', 'treeitem'], - table: ['grid'], - td: ['gridcell'], - }, - ], - 'jsx-a11y/no-noninteractive-tabindex': [ - 'error', - { - tags: [], - roles: ['tabpanel'], - }, - ], - 'jsx-a11y/no-redundant-roles': 'error', - 'jsx-a11y/no-static-element-interactions': [ - 'error', - { - handlers: ['onClick', 'onMouseDown', 'onMouseUp', 'onKeyPress', 'onKeyDown', 'onKeyUp'], - }, - ], - 'jsx-a11y/role-has-required-aria-props': 'error', - 'jsx-a11y/role-supports-aria-props': 'error', - 'jsx-a11y/scope': 'error', - 'jsx-a11y/tabindex-no-positive': 'error', - 'react/rules-of-hooks': 'error', - 'react/exhaustive-deps': 'error', - 'react/static-components': 'error', - 'react/use-memo': 'error', - 'react/preserve-manual-memoization': 'error', - 'react/incompatible-library': 'error', - 'react/immutability': 'error', - 'react/globals': 'error', - 'react/refs': 'error', - 'react/set-state-in-effect': 'error', - 'react/error-boundaries': 'error', - 'react/purity': 'error', - 'react/set-state-in-render': 'error', - 'react/unsupported-syntax': 'error', - 'typescript/dot-notation': [ - 'error', - { - allowKeywords: true, - }, - ], - 'core/dot-notation': [ - 'error', - { - allowKeywords: true, - }, - ], - 'core/no-octal': 'error', - 'core/no-octal-escape': 'error', - 'core/no-return-await': 'error', - 'core/no-dupe-args': 'error', - 'core/no-unreachable-loop': [ - 'error', - { - ignore: [], - }, - ], - 'core/no-new-symbol': 'error', - 'core/no-restricted-exports': [ - 'error', - { - restrictedNamedExports: ['default', 'then'], - }, - ], - 'core/no-restricted-syntax': [ - 'error', - { - selector: 'ForInStatement', - message: - 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', - }, - { - selector: 'LabeledStatement', - message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', - }, - { - selector: 'WithStatement', - message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize. It is also deprecated.', - }, - ], - 'hosted/export': 'error', - 'hosted/no-extraneous-dependencies': 'off', - 'hosted/order': [ - 'error', - { - groups: [['builtin', 'external', 'internal']], - }, - ], - 'hosted/prefer-default-export': 'error', - 'hosted/no-useless-path-segments': [ - 'error', - { - commonjs: true, - }, - ], - 'hosted/no-import-module-exports': [ - 'error', - { - exceptions: [], - }, - ], - 'hosted/no-relative-packages': 'error', - 'core/strict': ['error', 'never'], - 'core/no-new-object': 'error', - 'core/no-undef-init': 'error', - 'hosted/forbid-prop-types': [ - 'error', - { - forbid: ['any', 'array', 'object'], - checkContextTypes: true, - checkChildContextTypes: true, - }, - ], - 'hosted/jsx-no-bind': [ - 'error', - { - ignoreRefs: true, - allowArrowFunctions: true, - allowFunctions: false, - allowBind: false, - ignoreDOMComponents: true, - }, - ], - 'hosted/no-deprecated': ['error'], - 'hosted/prefer-stateless-function': [ - 'error', - { - ignorePureComponents: true, - }, - ], - 'hosted/require-render-return': 'error', - 'hosted/sort-comp': [ - 'error', - { - order: [ - 'static-variables', - 'static-methods', - 'instance-variables', - 'lifecycle', - '/^handle.+$/', - '/^on.+$/', - 'getters', - 'setters', - '/^(get|set)(?!(InitialState$|DefaultProps$|ChildContext$)).+$/', - 'instance-methods', - 'everything-else', - 'rendering', - ], - groups: { - lifecycle: [ - 'displayName', - 'propTypes', - 'contextTypes', - 'childContextTypes', - 'mixins', - 'statics', - 'defaultProps', - 'constructor', - 'getDefaultProps', - 'getInitialState', - 'state', - 'getChildContext', - 'getDerivedStateFromProps', - 'componentWillMount', - 'UNSAFE_componentWillMount', - 'componentDidMount', - 'componentWillReceiveProps', - 'UNSAFE_componentWillReceiveProps', - 'shouldComponentUpdate', - 'componentWillUpdate', - 'UNSAFE_componentWillUpdate', - 'getSnapshotBeforeUpdate', - 'componentDidUpdate', - 'componentDidCatch', - 'componentWillUnmount', - ], - rendering: ['/^render.+$/', 'render'], - }, - }, - ], - 'hosted/forbid-foreign-prop-types': [ - 'error', - { - allowInPropTypes: true, - }, - ], - 'hosted/default-props-match-prop-types': [ - 'error', - { - allowRequiredDefaults: false, - }, - ], - 'hosted/no-unused-state': 'error', - 'hosted/no-typos': 'error', - 'hosted/no-access-state-in-setstate': 'error', - 'hosted/static-property-placement': ['error', 'property assignment'], - 'hosted/prefer-exact-props': 'error', - 'hosted/no-arrow-function-lifecycle': 'error', - 'hosted/no-invalid-html-attribute': 'error', - 'hosted/no-unused-class-component-methods': 'error', - }, - overrides: [ - { - files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], - plugins: ['jsdoc'], - rules: { - 'no-redeclare': 'error', - 'es/no-nullish-coalescing-operators': 'error', - 'es/no-optional-chaining': 'error', - 'jsdoc/require-param': 'error', - 'jsdoc/require-param-type': 'error', - 'jsdoc/check-tag-names': 'error', - }, - }, - { - files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], - rules: { - 'constructor-super': 'off', - 'getter-return': 'off', - 'no-class-assign': 'off', - 'no-const-assign': 'off', - 'no-dupe-class-members': 'error', - 'no-dupe-keys': 'off', - 'no-func-assign': 'off', - 'no-import-assign': 'off', - 'no-new-native-nonconstructor': 'off', - 'no-obj-calls': 'off', - 'no-redeclare': 'error', - 'no-setter-return': 'off', - 'no-this-before-super': 'off', - 'no-unreachable': 'off', - 'no-unsafe-negation': 'off', - 'no-with': 'off', - 'prefer-const': [ - 'error', - { - destructuring: 'any', - ignoreReadBeforeAssign: true, - }, - ], - 'no-implied-eval': 'off', - 'no-unused-expressions': [ - 'error', - { - allowShortCircuit: false, - allowTernary: false, - allowTaggedTemplates: false, - }, - ], - 'no-unused-vars': [ - 'error', - { - vars: 'all', - args: 'after-used', - caughtErrors: 'none', - ignoreRestSiblings: true, - reportVarsOnlyUsedAsTypes: true, - }, - ], - 'no-throw-literal': 'error', - 'prefer-promise-reject-errors': [ - 'error', - { - allowEmptyReject: true, - }, - ], - 'typescript/await-thenable': 'error', - 'typescript/ban-ts-comment': 'error', - 'typescript/no-array-delete': 'error', - 'typescript/no-base-to-string': 'error', - 'typescript/no-duplicate-enum-values': 'error', - 'typescript/no-duplicate-type-constituents': [ - 'error', - { - ignoreUnions: true, - }, - ], - 'typescript/no-empty-object-type': 'error', - 'typescript/no-explicit-any': 'error', - 'typescript/no-extra-non-null-assertion': 'error', - 'typescript/no-floating-promises': 'off', - 'typescript/no-for-in-array': 'error', - 'typescript/no-implied-eval': 'error', - 'typescript/no-misused-new': 'error', - 'typescript/no-misused-promises': 'error', - 'typescript/no-namespace': 'error', - 'typescript/no-non-null-asserted-optional-chain': 'error', - 'typescript/no-redundant-type-constituents': 'error', - 'typescript/no-require-imports': 'off', - 'typescript/no-this-alias': 'error', - 'typescript/no-unnecessary-type-assertion': 'error', - 'typescript/no-unnecessary-type-constraint': 'error', - 'typescript/no-unsafe-argument': 'error', - 'typescript/no-unsafe-assignment': 'error', - 'typescript/no-unsafe-call': 'error', - 'typescript/no-unsafe-declaration-merging': 'error', - 'typescript/no-unsafe-enum-comparison': 'error', - 'typescript/no-unsafe-function-type': 'error', - 'typescript/no-unsafe-member-access': 'error', - 'typescript/no-unsafe-return': 'error', - 'typescript/no-unsafe-unary-minus': 'error', - 'typescript/no-wrapper-object-types': 'error', - 'typescript/only-throw-error': 'off', - 'typescript/prefer-as-const': 'error', - 'typescript/prefer-namespace-keyword': 'error', - 'typescript/prefer-promise-reject-errors': 'off', - 'typescript/require-await': 'off', - 'typescript/restrict-plus-operands': 'error', - 'typescript/restrict-template-expressions': 'error', - 'typescript/triple-slash-reference': 'error', - 'typescript/unbound-method': 'error', - 'typescript/adjacent-overload-signatures': 'error', - 'typescript/array-type': [ - 'error', - { - default: 'array-simple', - }, - ], - 'typescript/ban-tslint-comment': 'error', - 'typescript/class-literal-property-style': 'error', - 'typescript/consistent-generic-constructors': 'error', - 'typescript/consistent-indexed-object-style': 'error', - 'typescript/consistent-type-assertions': 'error', - 'typescript/consistent-type-definitions': ['error', 'type'], - 'typescript/no-confusing-non-null-assertion': 'error', - 'typescript/no-inferrable-types': 'error', - 'typescript/non-nullable-type-assertion-style': 'error', - 'typescript/prefer-find': 'off', - 'typescript/prefer-for-of': 'error', - 'typescript/prefer-function-type': 'error', - 'typescript/prefer-includes': 'off', - 'typescript/prefer-nullish-coalescing': [ - 'error', - { - ignoreIfStatements: true, - ignorePrimitives: {}, - ignoreTernaryTests: true, - }, - ], - 'typescript/prefer-regexp-exec': 'off', - 'typescript/prefer-string-starts-ends-with': 'error', - 'no-new-func': 'off', - 'no-use-before-define': [ - 'error', - { - functions: false, - }, - ], - 'import/extensions': [ - 'error', - 'ignorePackages', - { - js: 'never', - mjs: 'never', - jsx: 'never', - ts: 'never', - tsx: 'never', - cts: 'never', - mts: 'never', - }, - ], - 'valid-typeof': 'off', - 'import/no-named-as-default-member': 'off', - 'unicorn/prefer-at': 'error', - 'rulesdir/prefer-underscore-method': 'off', - 'jsdoc/require-param': 'off', - 'max-params': [ - 'error', - { - max: 10, - }, - ], - 'typescript/return-await': ['error', 'in-try-catch'], - 'typescript/no-var-requires': 'off', - 'typescript/no-unsafe-type-assertion': 'error', - 'typescript/switch-exhaustiveness-check': [ - 'error', - { - considerDefaultExhaustiveForUnions: true, - }, - ], - 'typescript/no-restricted-types': [ - 'error', - { - types: { - object: "Use 'Record' instead.", - }, - }, - ], - 'typescript/consistent-type-imports': [ - 'error', - { - prefer: 'type-imports', - fixStyle: 'separate-type-imports', - }, - ], - 'typescript/consistent-type-exports': [ - 'error', - { - fixMixedExportsWithInlineTypeSpecifier: false, - }, - ], - 'typescript/no-deprecated': 'error', - 'typescript/no-non-null-assertion': 'error', - 'typescript/prefer-enum-initializers': 'error', - 'typescript/no-import-type-side-effects': 'error', - }, - plugins: ['jsdoc'], - }, - { - files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], - rules: { - 'typescript/await-thenable': 'off', - 'typescript/consistent-type-exports': 'off', - 'typescript/dot-notation': 'off', - 'typescript/no-array-delete': 'off', - 'typescript/no-base-to-string': 'off', - 'typescript/no-confusing-void-expression': 'off', - 'typescript/no-deprecated': 'off', - 'typescript/no-duplicate-type-constituents': 'off', - 'typescript/no-floating-promises': 'off', - 'typescript/no-for-in-array': 'off', - 'typescript/no-generated-empty-object-type': 'off', - 'typescript/no-implied-eval': 'off', - 'typescript/no-meaningless-void-operator': 'off', - 'typescript/no-misused-promises': 'off', - 'typescript/no-misused-spread': 'off', - 'typescript/no-mixed-enums': 'off', - 'typescript/no-redundant-type-constituents': 'off', - 'typescript/no-unnecessary-boolean-literal-compare': 'off', - 'typescript/no-unnecessary-qualifier': 'off', - 'typescript/no-unnecessary-template-expression': 'off', - 'typescript/no-unnecessary-type-arguments': 'off', - 'typescript/no-unnecessary-type-assertion': 'off', - 'typescript/no-unnecessary-type-conversion': 'off', - 'typescript/no-unnecessary-type-parameters': 'off', - 'typescript/no-unsafe-argument': 'off', - 'typescript/no-unsafe-assignment': 'off', - 'typescript/no-unsafe-call': 'off', - 'typescript/no-unsafe-enum-assignment': 'off', - 'typescript/no-unsafe-enum-comparison': 'off', - 'typescript/no-unsafe-member-access': 'off', - 'typescript/no-unsafe-return': 'off', - 'typescript/no-unsafe-type-assertion': 'off', - 'typescript/no-unsafe-unary-minus': 'off', - 'typescript/no-useless-default-assignment': 'off', - 'typescript/non-nullable-type-assertion-style': 'off', - 'typescript/only-throw-error': 'off', - 'typescript/prefer-find': 'off', - 'typescript/prefer-includes': 'off', - 'typescript/prefer-nullish-coalescing': 'off', - 'typescript/prefer-promise-reject-errors': 'off', - 'typescript/prefer-readonly': 'off', - 'typescript/prefer-readonly-parameter-types': 'off', - 'typescript/prefer-reduce-type-parameter': 'off', - 'typescript/prefer-regexp-exec': 'off', - 'typescript/prefer-return-this-type': 'off', - 'typescript/prefer-string-starts-ends-with': 'off', - 'typescript/promise-function-async': 'off', - 'typescript/related-getter-setter-pairs': 'off', - 'typescript/require-array-sort-compare': 'off', - 'typescript/require-await': 'off', - 'typescript/restrict-plus-operands': 'off', - 'typescript/restrict-template-expressions': 'off', - 'typescript/return-await': 'off', - 'typescript/strict-boolean-expressions': 'off', - 'typescript/strict-void-return': 'off', - 'typescript/switch-exhaustiveness-check': 'off', - 'typescript/unbound-method': 'off', - 'typescript/use-unknown-in-catch-callback-variable': 'off', - }, - }, - { - files: ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts'], - plugins: ['jsdoc'], - env: { - jest: true, - }, - rules: { - 'no-console': 'off', - 'no-await-in-loop': 'off', - 'no-import-assign': 'off', - 'jsdoc/check-tag-names': [ - 'error', - { - definedTags: ['jest-environment'], - }, - ], - }, - }, - { - files: ['**/scripts/**', '**/server/**', '**/.github/**'], - rules: { - 'no-console': 'off', - }, - }, - { - files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], - env: { - commonjs: true, - node: true, - }, - }, - { - files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], - rules: { - 'import/extensions': 'off', - }, - }, - { - files: ['lib/storage/providers/MemoryOnlyProvider.ts'], - env: { - jest: true, - }, - }, - { - files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], - rules: { - 'typescript/dot-notation': 'off', - }, - }, - { - files: ['tests/perf-test/**/*'], - rules: { - 'unicorn/prefer-at': 'off', - }, - }, - { - files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], - rules: { - 'hosted/named': 'error', - 'hosted/check-param-names': 'error', - 'hosted/check-types': [ - 'error', - { - exemptTagContexts: [ - { - tag: 'param', - types: ['Object'], - }, - { - tag: 'returns', - types: ['Object'], - }, - ], - noDefaults: true, - }, - ], - }, - }, - { - files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts'], - rules: { - 'hosted/no-types': 'warn', - }, - }, - { - files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], - rules: { - 'core/no-undef': 'error', - }, - }, - { - files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], - rules: { - 'core/no-dupe-args': 'off', - 'core/no-new-symbol': 'off', - 'core/no-undef': 'off', - 'core/dot-notation': 'off', - 'core/no-return-await': 'off', - 'hosted/no-extraneous-dependencies': [ - 'warn', - { - devDependencies: [ - 'tests/**', - 'test/**', - 'spec/**', - '**/__tests__/**', - '**/__mocks__/**', - 'test.{js,jsx}', - 'test.{ts,tsx}', - 'test-*.{js,jsx}', - 'test-*.{ts,tsx}', - '**/*.{test,spec}.{js,jsx,mjs,cjs}', - '**/*.{test,spec}.{ts,tsx,mjs,cjs}', - '**/*.{test,spec}.{ts,tsx,cts,mts}', - '**/*_test.{js,jsx,ts,tsx}', - '**/*_test.{ts,tsx,ts,tsx}', - '**/jest.config.{js,ts,mts}', - '**/jest.config.{ts,ts,mts}', - '**/jest.setup.{js,ts}', - '**/jest.setup.{ts,ts}', - 'jestSetup.js', - 'jestSetup.ts', - '**/vue.config.js', - '**/vue.config.ts', - '**/webpack.config.js', - '**/webpack.config.ts', - '**/webpack.config.*.js', - '**/webpack.config.*.ts', - '**/rollup.config.js', - '**/rollup.config.ts', - '**/rollup.config.*.js', - '**/rollup.config.*.ts', - '**/gulpfile.js', - '**/gulpfile.ts', - '**/gulpfile.*.js', - '**/gulpfile.*.ts', - '**/Gruntfile{,.js}', - '**/Gruntfile{,.ts}', - '**/protractor.conf.js', - '**/protractor.conf.ts', - '**/protractor.conf.*.js', - '**/protractor.conf.*.ts', - '**/karma.conf.js', - '**/karma.conf.ts', - '**/.eslintrc.js', - '**/.eslintrc.ts', - '**/vitest.config.{ts,mts}', - '**/vite.config.{ts,mts}', - '**/eslint.config.{mjs,mts}', - ], - optionalDependencies: false, - }, - ], - 'hosted/named': 'off', - 'hosted/naming-convention': [ - 'warn', - { - selector: ['variable', 'property'], - format: null, - filter: { - regex: '^__esModule$', - match: true, - }, - }, - { - selector: ['variable', 'property'], - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - filter: { - regex: '^private_[a-z][a-zA-Z0-9]*$', - match: false, - }, - }, - { - selector: 'function', - format: ['camelCase', 'PascalCase'], - }, - { - selector: ['typeLike', 'enumMember'], - format: ['PascalCase'], - }, - { - selector: ['parameter', 'method'], - format: ['camelCase', 'PascalCase'], - leadingUnderscore: 'allow', - }, - ], - 'core/lines-between-class-members': [ - 'error', - 'always', - { - exceptAfterSingleLine: false, - }, - ], - }, - }, - { - files: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], - rules: { - 'hosted/naming-convention': 'off', - }, - }, - { - files: ['**/scripts/**', '**/server/**', '**/.github/**'], - rules: { - 'hosted/no-extraneous-dependencies': 'off', - }, - }, - { - files: ['tests/**/*'], - rules: { - 'hosted/naming-convention': [ - 'warn', - { - selector: ['variable', 'property'], - format: null, - filter: { - regex: '^__esModule$', - match: true, - }, - }, - { - selector: ['variable', 'property'], - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - filter: { - regex: '^private_[a-z][a-zA-Z0-9]*$', - match: false, - }, - }, - { - selector: 'objectLiteralProperty', - format: null, - filter: { - regex: '_', - match: true, - }, - }, - { - selector: 'objectLiteralProperty', - format: null, - filter: { - regex: '^[0-9]+$', - match: true, - }, - }, - { - selector: 'function', - format: ['camelCase', 'PascalCase'], - }, - { - selector: ['typeLike', 'enumMember'], - format: ['PascalCase'], - }, - { - selector: ['parameter', 'method'], - format: ['camelCase', 'PascalCase'], - leadingUnderscore: 'allow', - }, - ], - }, - }, - ], -}); +import {oxlintConfig} from './config/oxlint/derive.mjs'; -// Count-threshold gate: rules with existing violations report as warnings, and `npm run lint:oxlint` fails above -// the warning count it passes to --max-warnings. See config/lintDebt.mjs. // Annotated because the inferred type is too large for TypeScript to serialize (declaration: true). -const gatedConfig: OxlintConfig = defineConfig({ - ...config, - rules: downgradeDebtRules(config.rules, oxlintConfigRuleToESLintRuleIDs), - overrides: config.overrides?.map((override) => ({...override, rules: downgradeDebtRules(override.rules, oxlintConfigRuleToESLintRuleIDs)})), -}); +const config: OxlintConfig = defineConfig(oxlintConfig); -export default gatedConfig; +export default config; diff --git a/package-lock.json b/package-lock.json index 9abfee5bf..194d98d2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@jest/globals": "^29.7.0", "@lwc/eslint-plugin-lwc": "^3.5.0", "@ngneat/falso": "^7.3.0", + "@oxlint/migrate": "1.90.0", "@react-native-community/eslint-config": "^3.2.0", "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", @@ -51,7 +52,6 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-rulesdir": "^0.2.2", "fake-indexeddb": "^6.2.5", - "globals": "^15.15.0", "idb-keyval": "^6.2.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", @@ -3590,6 +3590,363 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.155.0.tgz", + "integrity": "sha512-A6xv7hDtnTm+xlZ7utn6rRQpRMJHRhVr3gjRGqlx0RUX84mNk0pAsy6YN/wWhuOTkaZhRrrzaLxf0NfbH77j2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.155.0.tgz", + "integrity": "sha512-T1wWwH9/JI5g7Z4kEQwDOwhToxdDhRyOTklH+uuNpJ16m3MZFmGl+l2rKc0NHQdbVRG2LTOBsNjpW3h1BWytPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.155.0.tgz", + "integrity": "sha512-ZoRB3qJxJ8DWA41jHja+YeB0lLfjBy4T+Kr15vnN1k65X3zbJt3rPZftEwN4Ju4tmA4K/T9dWWD7Z8ewQLnrVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.155.0.tgz", + "integrity": "sha512-vxtL86BtM+IgrpejA86nbTk5j0HKTIMeuaJ1fQciw6p9RfavPtIPjI7gN34owap0yG9txXBaAWpGdxokNwdqIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.155.0.tgz", + "integrity": "sha512-tN6Ru5e0UVnWaIuiH8jEwnvxuxg7FvhWwSd3oe5oDhOD4ObsSr9/N6WfHLjilUWlPPj65ptwrSDH0Y7lzwTzmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.155.0.tgz", + "integrity": "sha512-h4ozA5n6L6lwolsz9iwunEhKWnIVbmFRots5QxEix0H6nJ5ICpuDa7PuShuhfpIDFe3+Qvyscioyiv+9g+4Syg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.155.0.tgz", + "integrity": "sha512-058/+LardpfuUlsKIvBGk/CYoakEVcMgqYx8GgyUZeOxFWTojYLlCc1Gb60/y21v3esDhHidgiY/Zbq9LpBhaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.155.0.tgz", + "integrity": "sha512-nrOt2Y3xIAzu+lSPzH0864ieO+iCCXgoo9qFlGANE8Xh0cQ+RP8Wstfp+/wXN2YCyb98cAGzX0VruKYw7it/jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.155.0.tgz", + "integrity": "sha512-KYAayyN5hQbJPtGKtRxlyc8XYmyntOplyXSQzTDrmbz7l/GCrzQbSswvyJFBTH1LGtsZrbXOAfN52i/Kz6kLKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.155.0.tgz", + "integrity": "sha512-CSuSY5hfjtsBnHXydOj8jiNfS1qHbdPZuPz1YL1cSgK7VmNYP+QlZIbG0BBYYBgX/vYEgZwbG9mBdW4YairZ5w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.155.0.tgz", + "integrity": "sha512-7KCfOQm+Nz2N8trAXbQkal4TQ5756WVEmhm35y816YHvyFnEfVsICsiV5k0pEszNf/fRm4cm2i6i18SJ6/iCWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.155.0.tgz", + "integrity": "sha512-JSlhgoK+/g1TGJI5NuADx2+6syhxnQNLCWLXyO5QLFyetomcr/vIxSdiNlmyAv963YXUzCuwAwRG6npZ5xyJ1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.155.0.tgz", + "integrity": "sha512-WFIX+dPokWp64mi434Md3U3lJaTwNxAphvGjpltoyQjGUsdXavOIRrnp9dOS34Hd75HOAwDxAI+YwWOwqmJbtg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.155.0.tgz", + "integrity": "sha512-g6xSDC/JpbnVOz5uafMGyK3txBVRFMQTx8opv/yHXGxqV3ZMVl3bf7W8XrSnF68dmy3gM4dgReNryuE53+WkeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.155.0.tgz", + "integrity": "sha512-6/4xlxKlZJWrQ0uwDFPc0H9+xLu7068CKhX3vIDW3SbvP/lVgnVuT5v6dyugeWskWpuxqF/WJ4WKwhxjU3pG2w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.155.0.tgz", + "integrity": "sha512-tltklL0J0XjDxIRTBXWOBhvc2mitcmES45pIxhwDWfxRyqz7Vkeu0NekTZEIxGE4f/lXgq3vZcjjffteQOVorg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.155.0.tgz", + "integrity": "sha512-alAb9TzjatUEsOeoncAziUnfdTDxfyswhalFZ9sC6plUgATIUx2NIZ0+bMn6aJ28AduS1OpG/OAisr/YISn4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.155.0.tgz", + "integrity": "sha512-yc5cbSfcGmRv/KpxDFuyk8adEBaWd/9HilWvBbQ4bFbus1V2PUh2mGB2Cfq9rqZTqLeiMT8x0skV+LKp9hOMgQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.155.0.tgz", + "integrity": "sha512-8heq6jGq+rfpTDRDjoQiBHw0maQZf3IXkS99pdHQZPoTL5kyF5qyPS0RidUlSdnWDIqxXb5nux7GBD9cT+imUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@oxlint-tsgolint/darwin-arm64": { "version": "7.0.2005", "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2005.tgz", @@ -4021,6 +4378,45 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxlint/migrate": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@oxlint/migrate/-/migrate-1.90.0.tgz", + "integrity": "sha512-NfilzgljTDkgH6CAiHo/joWNZ6/4SKxbS74HW2rpWXVn8Wc/qcOz9b8u0LaFbNLp1ADl9XF2BVsUC5DzCKFwjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^15.0.0", + "globals": "^17.0.0", + "oxc-parser": "^0.155.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "migrate": "dist/bin/oxlint-migrate.mjs" + } + }, + "node_modules/@oxlint/migrate/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@oxlint/migrate/node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@react-native-community/eslint-config": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.2.0.tgz", @@ -13656,6 +14052,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxc-parser": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.155.0.tgz", + "integrity": "sha512-C/ReSf1JUFukK/zvk6jhcNibSssvas5+ktIzK7fbKVd3CedWe59C3QwHEOrEZj/4Rt9+xg0Jeis/Jv1F/KM1mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.155.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.155.0", + "@oxc-parser/binding-android-arm64": "0.155.0", + "@oxc-parser/binding-darwin-arm64": "0.155.0", + "@oxc-parser/binding-darwin-x64": "0.155.0", + "@oxc-parser/binding-freebsd-x64": "0.155.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.155.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.155.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.155.0", + "@oxc-parser/binding-linux-arm64-musl": "0.155.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.155.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.155.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.155.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.155.0", + "@oxc-parser/binding-linux-x64-gnu": "0.155.0", + "@oxc-parser/binding-linux-x64-musl": "0.155.0", + "@oxc-parser/binding-openharmony-arm64": "0.155.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.155.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.155.0", + "@oxc-parser/binding-win32-x64-msvc": "0.155.0" + } + }, "node_modules/oxlint": { "version": "1.90.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.90.0.tgz", diff --git a/package.json b/package.json index 630d8d621..bcd9d01cf 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "scripts": { "prepare": "npm run build", "lint": "eslint . --max-warnings 713", - "lint:oxlint": "oxlint --type-aware --max-warnings 665", + "lint:oxlint": "oxlint --max-warnings 665", "typecheck": "tsc --noEmit", "test": "jest", "test:types": "npm run build && tsc --noEmit --project tsconfig.test.json", @@ -58,6 +58,7 @@ "@jest/globals": "^29.7.0", "@lwc/eslint-plugin-lwc": "^3.5.0", "@ngneat/falso": "^7.3.0", + "@oxlint/migrate": "1.90.0", "@react-native-community/eslint-config": "^3.2.0", "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", @@ -86,7 +87,6 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-rulesdir": "^0.2.2", "fake-indexeddb": "^6.2.5", - "globals": "^15.15.0", "idb-keyval": "^6.2.1", "jest": "^29.7.0", "jest-cli": "^29.7.0", From 7010e3cf04cc33ad453ed0869bd72b40d224636a Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 08:48:00 +0100 Subject: [PATCH 09/43] Drop Oxlint rule-name mapping and directive wrapper, migrate disable comments --- config/lintDebt.mjs | 5 +- config/oxlint/derive.mjs | 15 +-- config/oxlint/eslintDirectives.mjs | 176 ------------------------- config/oxlint/plugins/core-rules.mjs | 3 +- config/oxlint/plugins/hosted-rules.mjs | 7 +- config/oxlint/ruleNames.mjs | 93 ------------- lib/OnyxCache.ts | 6 +- lib/utils.ts | 6 +- tests/types.ts | 2 +- 9 files changed, 18 insertions(+), 295 deletions(-) delete mode 100644 config/oxlint/eslintDirectives.mjs delete mode 100644 config/oxlint/ruleNames.mjs diff --git a/config/lintDebt.mjs b/config/lintDebt.mjs index 7c0d2c165..ea8df79c5 100644 --- a/config/lintDebt.mjs +++ b/config/lintDebt.mjs @@ -2,11 +2,10 @@ * Count-threshold lint gate (experiment, replaces a seatbelt baseline). * * Every rule that had violations when eslint-config-expensify 4.x was adopted is downgraded from `error` to - * `warn` in both eslint.config.mjs and oxlint.config.mts, and CI runs the linters with `--max-warnings ` + * `warn` in eslint.config.mjs and in config/oxlint/sourceConfig.mjs (which the Oxlint config is derived from), and + * CI runs the linters with `--max-warnings ` * (see the `lint` and `lint:oxlint` scripts in package.json). Errors still fail on their own, so any rule * not listed here stays strict. Shrink this list (and the thresholds) as rules reach zero. - * - * Rule ids are ESLint ids; oxlint.config.mts maps its own keys to them with config/oxlint/ruleNames.mjs. */ const LINT_DEBT_RULES = new Set([ '@typescript-eslint/consistent-type-definitions', diff --git a/config/oxlint/derive.mjs b/config/oxlint/derive.mjs index 109e2b9af..a77662835 100644 --- a/config/oxlint/derive.mjs +++ b/config/oxlint/derive.mjs @@ -154,14 +154,11 @@ for (const ruleId of [...skipped, ...ALWAYS_HOSTED]) { throw new Error(`Oxlint cannot run ${ruleId} natively or as a JS plugin. Add it to NOT_PORTED or STAND_INS in config/oxlint/derive.mjs.`); } -// Hosted rules share the `hosted` namespace, so this maps each one back to its ESLint plugin. -const HOSTED_RULE_ORIGIN = {}; -for (const ruleId of hostedRuleIds) { - const {prefix, name} = splitRuleId(ruleId); - if (HOSTED_RULE_ORIGIN[name]) { - throw new Error(`hosted/${name} would stand for both ${HOSTED_RULE_ORIGIN[name]}/${name} and ${ruleId}`); - } - HOSTED_RULE_ORIGIN[name] = prefix; +// Hosted rules share the `hosted` namespace, so two ESLint plugins must not bring the same rule name. +const hostedNames = hostedRuleIds.map((ruleId) => splitRuleId(ruleId).name); +const duplicateName = hostedNames.find((name, index) => hostedNames.indexOf(name) !== index); +if (duplicateName) { + throw new Error(`Two hosted rules would both be named hosted/${duplicateName}`); } /** @@ -336,4 +333,4 @@ const oxlintConfig = { ], }; -export {HOSTED_RULE_ORIGIN, STAND_INS, STUBBED_PARSER_SERVICES, coreRuleNames, hostedRuleIds, oxlintConfig, pluginRules, splitRuleId}; +export {STUBBED_PARSER_SERVICES, coreRuleNames, hostedRuleIds, oxlintConfig, pluginRules, splitRuleId}; diff --git a/config/oxlint/eslintDirectives.mjs b/config/oxlint/eslintDirectives.mjs deleted file mode 100644 index e0f5de6a5..000000000 --- a/config/oxlint/eslintDirectives.mjs +++ /dev/null @@ -1,176 +0,0 @@ -/** - * oxlint runs some ESLint rules as JS plugins under a new name. For example, - * `@typescript-eslint/naming-convention` becomes `hosted/naming-convention`. oxlint doesn't know the - * two are the same rule, so an existing `eslint-disable-next-line @typescript-eslint/naming-convention` - * comment wouldn't hide its report. The wrappers here read those comments and drop what ESLint would hide. - */ - -const JUSTIFICATION = /\s-{2,}\s/; -const KINDS = ['eslint-disable-next-line', 'eslint-disable-line', 'eslint-disable', 'eslint-enable']; -const ALL_RULES = ' all'; - -const cache = new Map(); - -/** - * Turns a comment like `eslint-disable-next-line rule-a, rule-b -- reason` into its kind and the rules - * it names. A directive that names no rule applies to every rule. - * @param {{type: 'Line' | 'Block', value: string, loc?: {start: {line: number, column: number}, end: {line: number, column: number}}}} comment - * @returns {{kind: string, ruleIds: string[]} | null} null when the comment is not a directive - */ -function parseComment(comment) { - const text = String(comment.value ?? '').trim(); - const kind = KINDS.find((candidate) => text === candidate || (text.startsWith(candidate) && /^\s/.test(text.slice(candidate.length)))); - if (!kind) { - return null; - } - const body = text.slice(kind.length).split(JUSTIFICATION)[0]; - const ruleIds = body - .split(',') - .map((id) => id.trim()) - .filter(Boolean); - return {kind, ruleIds: ruleIds.length ? ruleIds : [ALL_RULES]}; -} - -/** - * Collects a file's disable comments in a form that is quick to check for each report. A - * `-line`/`-next-line` comment becomes the line it covers. `eslint-disable`/`eslint-enable` comments - * stay in source order, because the last one before a report decides. - * @param {Array[0]>} comments - * @returns {{lines: Map>, blocks: Array<{line: number, column: number, ruleId: string, disable: boolean}>}} - */ -function parseDirectives(comments) { - const lines = new Map(); - const blocks = []; - - for (const comment of comments) { - const parsed = parseComment(comment); - const start = comment.loc?.start?.line; - const end = comment.loc?.end?.line; - if (!parsed || start == null || end == null) { - continue; - } - // ESLint reads `eslint-disable` and `eslint-enable` only from block comments. - const isBlockKind = parsed.kind === 'eslint-disable' || parsed.kind === 'eslint-enable'; - if (isBlockKind && comment.type !== 'Block') { - continue; - } - for (const ruleId of parsed.ruleIds) { - if (parsed.kind === 'eslint-disable-next-line' || parsed.kind === 'eslint-disable-line') { - // ESLint ignores a multi-line `-line` directive but applies a multi-line `-next-line` - // to the line after the comment ends. - if (parsed.kind === 'eslint-disable-line' && start !== end) { - continue; - } - if (!lines.has(ruleId)) { - lines.set(ruleId, new Set()); - } - lines.get(ruleId).add(parsed.kind === 'eslint-disable-line' ? start : end + 1); - } else { - blocks.push({line: start, column: comment.loc.start.column ?? 0, ruleId, disable: parsed.kind === 'eslint-disable'}); - } - } - } - - return {lines, blocks}; -} - -/** - * Every wrapped rule checks the same file's comments, so they are parsed once per file. - * @param {string | undefined} filename - * @param {Array[0]>} comments - * @returns {ReturnType} - */ -function directivesFor(filename, comments) { - const key = filename ?? ''; - if (!cache.has(key)) { - cache.set(key, parseDirectives(comments)); - } - return cache.get(key); -} - -/** - * Tells whether ESLint would hide a report of `eslintRuleId` at `position`. As in ESLint, the last - * `eslint-disable`/`eslint-enable` before the report, by line and then column, that names the rule - * (or no rule) wins. - * @param {ReturnType} directives - * @param {string} eslintRuleId - * @param {{line: number, column: number} | null} position - * @returns {boolean} - */ -function isSuppressed(directives, eslintRuleId, position) { - if (position == null) { - return false; - } - const {line, column} = position; - if (directives.lines.get(eslintRuleId)?.has(line) || directives.lines.get(ALL_RULES)?.has(line)) { - return true; - } - let disabled = false; - for (const block of directives.blocks) { - const isAfterReport = block.line > line || (block.line === line && block.column > column); - if (isAfterReport || (block.ruleId !== ALL_RULES && block.ruleId !== eslintRuleId)) { - continue; - } - disabled = block.disable; - } - return disabled; -} - -/** - * Finds the line and column a report points at, to check it against the disable comments. Rules pass - * either a descriptor with `loc` or `node`, or a node in the legacy `report(node, message)` form. - * @param {unknown[]} args - * @returns {{line: number, column: number} | null} - */ -function reportPosition(args) { - const first = args[0]; - const candidates = [first?.loc?.start, first?.loc, first?.node?.loc?.start, first?.node?.loc]; - const point = candidates.find((candidate) => typeof candidate?.line === 'number'); - return point ? {line: point.line, column: typeof point.column === 'number' ? point.column : 0} : null; -} - -/** - * Makes an oxlint JS plugin rule respect `eslint-disable` comments written for the ESLint rule it - * copies. Without it, `// eslint-disable-next-line react-hooks/exhaustive-deps` would not hide the - * oxlint copy's report, because oxlint calls that copy `hosted/exhaustive-deps`. - * @param {import('eslint').Rule.RuleModule} rule - * @param {string} eslintRuleId the ID ESLint reports this rule under, like `react-hooks/exhaustive-deps` - * @returns {import('eslint').Rule.RuleModule} - */ -function withEslintDirectiveIDs(rule, eslintRuleId) { - return { - ...rule, - create(context) { - const filteredContext = Object.create(context, { - report: { - // Keeps every argument: ESLint also accepts the legacy `report(node, message)` form. - value(...args) { - const position = reportPosition(args); - if (position != null) { - const sourceCode = context.sourceCode ?? context.getSourceCode(); - const filename = context.filename ?? context.getFilename(); - if (isSuppressed(directivesFor(filename, sourceCode.getAllComments()), eslintRuleId, position)) { - return; - } - } - return context.report(...args); - }, - }, - }); - return rule.create(filteredContext); - }, - }; -} - -/** - * withEslintDirectiveIDs for a whole plugin at once. `toEslintId` gives the ESLint name of each rule, - * like `naming-convention` to `@typescript-eslint/naming-convention`. - * @param {Record} rules - * @param {(name: string) => string} toEslintId maps an oxlint rule name to its ESLint ID - * @returns {Record} - */ -function withEslintDirectiveIDsFor(rules, toEslintId) { - return Object.fromEntries(Object.entries(rules).map(([name, rule]) => [name, withEslintDirectiveIDs(rule, toEslintId(name))])); -} - -export {withEslintDirectiveIDs, withEslintDirectiveIDsFor}; diff --git a/config/oxlint/plugins/core-rules.mjs b/config/oxlint/plugins/core-rules.mjs index 0a5389301..d8fda446b 100644 --- a/config/oxlint/plugins/core-rules.mjs +++ b/config/oxlint/plugins/core-rules.mjs @@ -9,14 +9,13 @@ import {builtinRules} from 'eslint/use-at-your-own-risk'; import {coreRuleNames} from '../derive.mjs'; -import {withEslintDirectiveIDsFor} from '../eslintDirectives.mjs'; const plugin = { meta: { name: 'core', version: '0.0.1', }, - rules: withEslintDirectiveIDsFor(Object.fromEntries(coreRuleNames.map((name) => [name, builtinRules.get(name)])), (name) => name), + rules: Object.fromEntries(coreRuleNames.map((name) => [name, builtinRules.get(name)])), }; export default plugin; diff --git a/config/oxlint/plugins/hosted-rules.mjs b/config/oxlint/plugins/hosted-rules.mjs index 30afee4ef..991af0b54 100644 --- a/config/oxlint/plugins/hosted-rules.mjs +++ b/config/oxlint/plugins/hosted-rules.mjs @@ -1,11 +1,9 @@ /** * Runs rules from ESLint plugins (typescript-eslint, import, jsdoc, react) inside Oxlint, using the plugins' * own code, for rules Oxlint has no native version of. Oxlint reserves those plugin names for its own - * ports, so all of them load under `hosted`; HOSTED_RULE_ORIGIN in config/oxlint/derive.mjs maps them back. - * config/oxlint/derive.mjs picks the rules. Adapted from Expensify/App config/oxlint/plugins/hosted-rules.mjs. + * ports, so all of them load under `hosted`. config/oxlint/derive.mjs picks the rules. Adapted from Expensify/App config/oxlint/plugins/hosted-rules.mjs. */ import {STUBBED_PARSER_SERVICES, hostedRuleIds, pluginRules, splitRuleId} from '../derive.mjs'; -import {withEslintDirectiveIDs} from '../eslintDirectives.mjs'; // Type-checked rules such as naming-convention ask for parser services, which Oxlint JS plugins lack. getParserServices(context, true) // tolerates a missing `program` but fails on missing node maps and reads context.languageOptions first, so @@ -49,8 +47,7 @@ const plugin = { hostedRuleIds.map((ruleId) => { const {prefix, name} = splitRuleId(ruleId); const rule = pluginRules(prefix)[name]; - // Makes `eslint-disable` comments that use the ESLint id apply to the hosted rule. - return [name, withEslintDirectiveIDs(STUBBED_PARSER_SERVICES.has(ruleId) ? withStubbedParserServices(rule) : rule, ruleId)]; + return [name, STUBBED_PARSER_SERVICES.has(ruleId) ? withStubbedParserServices(rule) : rule]; }), ), }; diff --git a/config/oxlint/ruleNames.mjs b/config/oxlint/ruleNames.mjs deleted file mode 100644 index e35d90dff..000000000 --- a/config/oxlint/ruleNames.mjs +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Oxlint and ESLint name some rules differently. This maps between the two, so Oxlint findings can be - * reported, compared and gated under ESLint's rule ids. Adapted from Expensify/App config/oxlint/ruleNames.mjs. - * Plain `.mjs` because Oxlint JS plugins cannot load TypeScript. - */ -import {HOSTED_RULE_ORIGIN, STAND_INS} from './derive.mjs'; - -const OXLINT_RULE_RENAMES = { - 'no-object-constructor': 'no-new-object', - 'no-new-native-nonconstructor': 'no-new-symbol', -}; - -// Oxlint native plugin names that differ from the ESLint plugin prefix. -const NATIVE_PLUGIN_PREFIX = { - typescript: '@typescript-eslint', - jsx_a11y: 'jsx-a11y', - react_perf: 'react-perf', -}; - -// ESLint runs the @typescript-eslint extension of these rules on TS files and the core rule on JS files. -// Oxlint has one native rule (reported as `eslint()`) that understands TypeScript, so the ESLint id -// depends on the file being linted. -const TS_EXTENSION_RULES = new Set([ - 'default-param-last', - 'max-params', - 'no-array-constructor', - 'no-dupe-class-members', - 'no-empty-function', - 'no-loop-func', - 'no-loss-of-precision', - 'no-redeclare', - 'no-shadow', - 'no-unused-expressions', - 'no-unused-vars', - 'no-use-before-define', - 'no-useless-constructor', -]); -// eslint-plugin-react-hooks rules that Oxlint implements inside its native `react` plugin. -const REACT_HOOKS_RULES = new Set([ - 'rules-of-hooks', - 'exhaustive-deps', - 'static-components', - 'use-memo', - 'preserve-manual-memoization', - 'incompatible-library', - 'immutability', - 'globals', - 'refs', - 'set-state-in-effect', - 'error-boundaries', - 'purity', - 'set-state-in-render', - 'unsupported-syntax', -]); - -const TS_FILE = /\.(ts|tsx|mts|cts)$/; - -const OXLINT_DIAGNOSTIC_CODE = /^([\w@/.-]+)\((.+)\)$/; - -/** - * Converts an Oxlint diagnostic code, like `typescript(no-explicit-any)`, to ESLint's rule id, like - * `@typescript-eslint/no-explicit-any`. A code that does not look like `plugin(rule)` comes back unchanged. - * @param {string} code - * @param {string} [filename] the linted file; needed to tell `no-unused-vars` from `@typescript-eslint/no-unused-vars` - * @returns {string} - */ -function oxlintCodeToESLintRuleID(code, filename = '') { - const match = OXLINT_DIAGNOSTIC_CODE.exec(code); - if (!match) { - return code; - } - const [, rawPlugin, rule] = match; - const plugin = rawPlugin.replace(/^eslint-plugin-/, ''); - if (plugin === 'eslint' && TS_EXTENSION_RULES.has(rule) && TS_FILE.test(filename)) { - return `@typescript-eslint/${rule}`; - } - if (plugin === 'eslint' || plugin === 'core') { - return OXLINT_RULE_RENAMES[rule] ?? rule; - } - if (plugin === 'react' && REACT_HOOKS_RULES.has(rule)) { - return `react-hooks/${rule}`; - } - if (plugin === 'hosted') { - const origin = HOSTED_RULE_ORIGIN[rule]; - if (!origin) { - throw new Error(`No HOSTED_RULE_ORIGIN entry for hosted rule "${rule}". Add it to config/oxlint/ruleNames.mjs.`); - } - return `${origin}/${rule}`; - } - return `${NATIVE_PLUGIN_PREFIX[plugin] ?? plugin}/${rule}`; -} - -export {HOSTED_RULE_ORIGIN, OXLINT_RULE_RENAMES, REACT_HOOKS_RULES, STAND_INS, TS_EXTENSION_RULES, oxlintCodeToESLintRuleID}; diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index 3003b5f1f..ccf2fca7a 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -227,7 +227,7 @@ class OnyxCache { const affectedCollections = new Set(); // Use for-in loop to avoid an unnecessary array allocation from Object.keys() - // eslint-disable-next-line no-restricted-syntax, guard-for-in + /* eslint-disable-next-line no-restricted-syntax, guard-for-in */ /* oxlint-disable-next-line core/no-restricted-syntax */ for (const key in data) { const value = data[key]; this.addKey(key); @@ -298,7 +298,7 @@ class OnyxCache { const affectedCollections = new Set(); // Use for-in loop to avoid an unnecessary array allocation from Object.entries() - // eslint-disable-next-line no-restricted-syntax, guard-for-in + /* eslint-disable-next-line no-restricted-syntax, guard-for-in */ /* oxlint-disable-next-line core/no-restricted-syntax */ for (const key in data) { const value = data[key]; this.addKey(key); @@ -522,7 +522,7 @@ class OnyxCache { // We can't rely on count comparison alone — if one key is removed and another added, // the counts match but the snapshot content is different. if (!hasMemberChanges && previousSnapshot) { - // eslint-disable-next-line no-restricted-syntax + /* eslint-disable-next-line no-restricted-syntax */ /* oxlint-disable-next-line core/no-restricted-syntax */ for (const key in previousSnapshot) { if (!(key in members)) { hasMemberChanges = true; diff --git a/lib/utils.ts b/lib/utils.ts index 164ae4792..8f7ddff8d 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -212,7 +212,7 @@ function isEmptyObject(obj: T | EmptyValue): obj is EmptyValue { } // Use for-in loop to avoid an unnecessary array allocation from Object.keys() - // eslint-disable-next-line no-restricted-syntax + /* eslint-disable-next-line no-restricted-syntax */ /* oxlint-disable-next-line core/no-restricted-syntax */ for (const key in obj) { if (Object.hasOwn(obj, key)) { return false; @@ -246,7 +246,7 @@ function needsNormalization(value: unknown): boolean { } // Use for-in loop to avoid an unnecessary array allocation from Object.keys() - // eslint-disable-next-line no-restricted-syntax, guard-for-in + /* eslint-disable-next-line no-restricted-syntax, guard-for-in */ /* oxlint-disable-next-line core/no-restricted-syntax */ for (const key in value) { if (key === ONYX_INTERNALS__REPLACE_OBJECT_MARK) { return true; @@ -288,7 +288,7 @@ function removeNestedNullValues | null>(value: let hasChanged = false; const result: Record = {}; - // eslint-disable-next-line no-restricted-syntax, guard-for-in + /* eslint-disable-next-line no-restricted-syntax, guard-for-in */ /* oxlint-disable-next-line core/no-restricted-syntax */ for (const key in value) { const propertyValue = value[key]; diff --git a/tests/types.ts b/tests/types.ts index 1aef55855..5503f5ed7 100644 --- a/tests/types.ts +++ b/tests/types.ts @@ -8,5 +8,5 @@ import type {DeepRecord} from '../lib/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericDeepRecord = DeepRecord; -// eslint-disable-next-line import/prefer-default-export +/* eslint-disable-next-line import/prefer-default-export */ /* oxlint-disable-next-line hosted/prefer-default-export */ export type {GenericDeepRecord}; From bf673e263c7057d77a48fad313ea676822a1d8c2 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 10:14:04 +0100 Subject: [PATCH 10/43] Keep earlier options for severity-only JS plugin rules and trim Oxlint config comments --- config/lintDebt.mjs | 32 ++---- config/oxlint/derive.mjs | 142 +++++++++---------------- config/oxlint/plugins/core-rules.mjs | 9 +- config/oxlint/plugins/hosted-rules.mjs | 18 +--- config/oxlint/sourceConfig.mjs | 18 +--- eslint.config.mjs | 10 +- oxlint.config.mts | 23 +--- 7 files changed, 74 insertions(+), 178 deletions(-) diff --git a/config/lintDebt.mjs b/config/lintDebt.mjs index ea8df79c5..233537068 100644 --- a/config/lintDebt.mjs +++ b/config/lintDebt.mjs @@ -1,12 +1,6 @@ -/** - * Count-threshold lint gate (experiment, replaces a seatbelt baseline). - * - * Every rule that had violations when eslint-config-expensify 4.x was adopted is downgraded from `error` to - * `warn` in eslint.config.mjs and in config/oxlint/sourceConfig.mjs (which the Oxlint config is derived from), and - * CI runs the linters with `--max-warnings ` - * (see the `lint` and `lint:oxlint` scripts in package.json). Errors still fail on their own, so any rule - * not listed here stays strict. Shrink this list (and the thresholds) as rules reach zero. - */ +// Rules that already had violations when eslint-config-expensify 4.x was adopted report as warnings instead of +// errors, and the `lint` and `lint:oxlint` scripts fail above the current warning count. Remove a rule here, and +// lower the counts, once it reaches zero. const LINT_DEBT_RULES = new Set([ '@typescript-eslint/consistent-type-definitions', '@typescript-eslint/naming-convention', @@ -37,12 +31,6 @@ const LINT_DEBT_RULES = new Set([ const isError = (severity) => severity === 'error' || severity === 2; -/** - * Turns an `error` rule entry into `warn`, keeping its options. `off` and `warn` entries are returned as is, - * so a rule is never switched on where the shared config turned it off. - * @param {unknown} entry a rule entry such as `'error'` or `['error', {...}]` - * @returns {unknown} - */ function downgradeToWarn(entry) { if (Array.isArray(entry)) { return isError(entry[0]) ? ['warn', ...entry.slice(1)] : entry; @@ -51,18 +39,12 @@ function downgradeToWarn(entry) { } /** - * Downgrades the debt rules in a rules object. `toESLintIds` maps a rule key to the ESLint ids it stands for. - * @template {Record | undefined} T + * @template {Record} T * @param {T} rules - * @param {(key: string) => string[]} [toESLintIds] * @returns {T} */ -function downgradeDebtRules(rules, toESLintIds) { - if (!rules) { - return rules; - } - const isDebt = (key) => (toESLintIds ? toESLintIds(key) : [key]).some((id) => LINT_DEBT_RULES.has(id)); - return /** @type {T} */ (Object.fromEntries(Object.entries(rules).map(([key, entry]) => [key, isDebt(key) ? downgradeToWarn(entry) : entry]))); +function downgradeDebtRules(rules) { + return /** @type {T} */ (Object.fromEntries(Object.entries(rules).map(([ruleId, entry]) => [ruleId, LINT_DEBT_RULES.has(ruleId) ? downgradeToWarn(entry) : entry]))); } -export {LINT_DEBT_RULES, downgradeDebtRules, downgradeToWarn}; +export default downgradeDebtRules; diff --git a/config/oxlint/derive.mjs b/config/oxlint/derive.mjs index a77662835..1e9ee0ce3 100644 --- a/config/oxlint/derive.mjs +++ b/config/oxlint/derive.mjs @@ -1,19 +1,8 @@ -/** - * Builds the Oxlint config from config/oxlint/sourceConfig.mjs (eslint-config-expensify presets plus Onyx's - * blocks) every time Oxlint loads it, so Oxlint follows eslint-config-expensify without a hand-kept rule list. - * - * 1. `@oxlint/migrate` converts the ESLint config and reports the rules it could not convert. - * 2. Known migrate pitfalls are fixed (invalid options, jsPlugins inside overrides, missing settings). - * 3. Converted rules that Oxlint cannot run natively are run with ESLint's own code through JS plugins - * (config/oxlint/plugins): ESLint core rules as `core/`, ESLint plugin rules as `hosted/`. - * Their severities and options are replayed from the source config block by block, in ESLint's order. - * - * A rule that can run neither natively nor as a JS plugin must be listed in NOT_PORTED, or config loading - * fails. That way a new rule in eslint-config-expensify gets a decision instead of being dropped silently. - * - * Config and JS plugins load in the same Node process, so the plugin files import the rule lists from here - * and this module runs once per Oxlint invocation. - */ +// Builds the Oxlint config from sourceConfig.mjs on every run, so eslint-config-expensify updates need no hand edits. +// @oxlint/migrate converts the rules Oxlint has natively. Rules it lacks run on ESLint's own code through ./plugins, +// with severities and options copied from the source config. A rule Oxlint cannot run at all must be listed in +// NOT_PORTED or STAND_INS, or loading fails, so a new preset rule is never dropped silently. +// Oxlint loads the config and the plugins in one Node process, so the plugins import their rule lists from here. import migrate from '@oxlint/migrate'; import {builtinRules} from 'eslint/use-at-your-own-risk'; import {createRequire} from 'node:module'; @@ -26,17 +15,12 @@ const require = createRequire(import.meta.url); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify', {paths: [repoRoot]})); -/** - * Resolves a package the way eslint-config-expensify does, so plugins it depends on load even when they are - * not direct dependencies of Onyx. - * @param {string} request - * @returns {string} - */ +// Plugins come in through eslint-config-expensify, not as Onyx dependencies. function resolvePackage(request) { return require.resolve(request, {paths: [expensifyConfigDirectory, repoRoot]}); } -// ESLint plugins whose names Oxlint reserves for its native ports. Their rules can only run under `hosted`. +// Oxlint reserves these plugin names for its own ports, so their missing rules run under `hosted`. const HOSTABLE_PLUGINS = { react: 'eslint-plugin-react', import: 'eslint-plugin-import', @@ -44,49 +28,40 @@ const HOSTABLE_PLUGINS = { '@typescript-eslint': '@typescript-eslint/eslint-plugin', }; -// Rules migrate converts to a native rule that behaves differently, hosted to keep ESLint's behavior. const ALWAYS_HOSTED = new Set([ - // Native import/prefer-default-export ignores `export type {...}`; ESLint's reports it (tests/types.ts). + // The native port ignores `export type {...}`, which ESLint reports (tests/types.ts). 'import/prefer-default-export', ]); -// Type-checked rules that still work as JS plugins once parser services are stubbed (see hosted-rules.mjs). -// Onyx's naming-convention options use no `types` selector, so the type checker is never reached. +// Type-checked rules that work with stubbed parser services (hosted-rules.mjs). This holds while Onyx's +// naming-convention options use no `types` selector, which would need the type checker. const STUBBED_PARSER_SERVICES = new Set(['@typescript-eslint/naming-convention']); -// Rules ESLint runs that Oxlint does not. const NOT_PORTED = { - 'no-invalid-this': 'calls sourceCode.getJSDocComment, which the JS plugin bridge lacks (the plugin throws and drops every JS-plugin finding in the file)', - 'react/jsx-uses-react': 'only marks variables as used; Oxlint no-unused-vars understands JSX', - 'react/jsx-uses-vars': 'only marks variables as used; Oxlint no-unused-vars understands JSX', - 'react-hooks/component-hook-factories': 'React Compiler diagnostic category that Oxlint does not implement', - 'react-hooks/config': 'React Compiler diagnostic category that Oxlint does not implement', - 'react-hooks/gating': 'React Compiler diagnostic category that Oxlint does not implement', - '@typescript-eslint/prefer-optional-chain': 'needs type information; the native rule is nursery', - 'rulesdir/boolean-conditional-rendering': 'needs type information; no Oxlint equivalent', + 'no-invalid-this': 'calls sourceCode.getJSDocComment, which Oxlint JS plugins lack; the error drops every JS plugin report in the file', + 'react/jsx-uses-react': 'only marks variables as used; Oxlint no-unused-vars handles JSX itself', + 'react/jsx-uses-vars': 'only marks variables as used; Oxlint no-unused-vars handles JSX itself', + 'react-hooks/component-hook-factories': 'React Compiler check that Oxlint does not implement', + 'react-hooks/config': 'React Compiler check that Oxlint does not implement', + 'react-hooks/gating': 'React Compiler check that Oxlint does not implement', + '@typescript-eslint/prefer-optional-chain': 'needs type information, and the native rule is still in nursery', + 'rulesdir/boolean-conditional-rendering': 'needs type information, and Oxlint has no equivalent', }; -// Native Oxlint rules configured in place of ESLint rules that cannot run in Oxlint (Oxlint rule: ESLint rule). -// unicorn/prefer-at keeps default options: `checkAllIndexAccess` adds hundreds of reports on tuple access that -// the type-aware ESLint rule skips. +// Native rules that replace ESLint rules Oxlint cannot run. unicorn/prefer-at keeps its defaults: `checkAllIndexAccess` +// adds hundreds of reports on tuple access that the type-aware ESLint rule skips. const STAND_INS = { 'unicorn/prefer-at': 'rulesdir/prefer-at', }; const STAND_IN_FOR = Object.fromEntries(Object.entries(STAND_INS).map(([oxlintRule, eslintRule]) => [eslintRule, oxlintRule])); -// Rules migrate copies with options that Oxlint rejects. +// Oxlint rejects the options migrate copies for these rules. const DROP_OPTIONS = new Set(['react/jsx-no-duplicate-props', 'react/jsx-no-undef']); -// Matches a glob for TypeScript files, like `**/*.ts`, `**/*.ts?(x)` or `**/*.{js,ts}`. const TYPESCRIPT_GLOB = /\.(\{[^}]*\b)?[cm]?tsx?\b/; const pluginRulesCache = new Map(); -/** - * Loads the rules of an installed ESLint plugin, so Oxlint runs the same rule code as ESLint. - * @param {string} prefix an ESLint rule prefix from HOSTABLE_PLUGINS, like `react` - * @returns {Record} - */ function pluginRules(prefix) { if (!pluginRulesCache.has(prefix)) { const mod = require(resolvePackage(HOSTABLE_PLUGINS[prefix])); @@ -95,20 +70,13 @@ function pluginRules(prefix) { return pluginRulesCache.get(prefix); } -/** - * @param {string} ruleId an ESLint rule id - * @returns {{prefix: string, name: string}} - */ function splitRuleId(ruleId) { const slash = ruleId.lastIndexOf('/'); return slash === -1 ? {prefix: '', name: ruleId} : {prefix: ruleId.slice(0, slash), name: ruleId.slice(slash + 1)}; } -/** - * Runs migrate and returns its config plus the rules it skipped. The second, non-type-aware pass finds ESLint - * core rules that migrate swaps for a type-aware typescript/* rule (like dot-notation). tsgolint only checks - * files in the TypeScript program, so those still need the core rule on JS files. - */ +// The non-type-aware pass finds core rules that migrate swaps for a type-aware typescript/* rule (dot-notation). +// tsgolint only checks files in the TypeScript program, so JS files still need the core rule. async function runMigrate() { const skipped = new Set(); const run = async (typeAware) => { @@ -154,18 +122,13 @@ for (const ruleId of [...skipped, ...ALWAYS_HOSTED]) { throw new Error(`Oxlint cannot run ${ruleId} natively or as a JS plugin. Add it to NOT_PORTED or STAND_INS in config/oxlint/derive.mjs.`); } -// Hosted rules share the `hosted` namespace, so two ESLint plugins must not bring the same rule name. +// Hosted rules share one namespace, so two plugins must not bring the same rule name. const hostedNames = hostedRuleIds.map((ruleId) => splitRuleId(ruleId).name); const duplicateName = hostedNames.find((name, index) => hostedNames.indexOf(name) !== index); if (duplicateName) { throw new Error(`Two hosted rules would both be named hosted/${duplicateName}`); } -/** - * Maps an ESLint rule id to the JS-plugin rule that runs it in Oxlint, if any. - * @param {string} ruleId - * @returns {string | undefined} - */ function jsPluginRuleFor(ruleId) { if (coreRuleNames.includes(ruleId)) { return `core/${ruleId}`; @@ -173,18 +136,8 @@ function jsPluginRuleFor(ruleId) { return hostedRuleIds.includes(ruleId) ? `hosted/${splitRuleId(ruleId).name}` : undefined; } -/** - * @param {unknown} entry - * @returns {unknown} - */ const severityOf = (entry) => (Array.isArray(entry) ? entry[0] : entry); -/** - * Fixes the rules of one migrated config block. - * @param {Record | undefined} rules - * @param {boolean} isTypeScriptBlock whether the block matches TypeScript files - * @returns {Record} - */ function fixRules(rules = {}, isTypeScriptBlock = false) { const out = {}; for (const [rule, entry] of Object.entries(rules)) { @@ -192,7 +145,7 @@ function fixRules(rules = {}, isTypeScriptBlock = false) { continue; } if (rule.startsWith('rulesdir/')) { - // eslint-plugin-expensify rules need type information, which Oxlint JS plugins lack. + // eslint-plugin-expensify rules need type information, which Oxlint JS plugins do not get. if (STAND_IN_FOR[rule]) { out[STAND_IN_FOR[rule]] = severityOf(entry); } else if (severityOf(entry) !== 'off' && !NOT_PORTED[rule]) { @@ -205,13 +158,13 @@ function fixRules(rules = {}, isTypeScriptBlock = false) { continue; } if (rule === 'import/no-cycle' && Array.isArray(entry)) { - // migrate copies ESLint's default maxDepth "∞", which Oxlint rejects; Oxlint's default is unlimited too. + // Oxlint rejects ESLint's maxDepth "∞"; its own default is already unlimited. const {maxDepth, ...options} = entry[1] ?? {}; out[rule] = maxDepth === '∞' ? [entry[0], options] : entry; continue; } if (rule === 'no-unused-vars' && isTypeScriptBlock && Array.isArray(entry)) { - // typescript-eslint's no-unused-vars reports values only used as types; Oxlint needs this option for that. + // typescript-eslint reports values used only as types; Oxlint does so only with this option. out[rule] = [entry[0], {...entry[1], reportVarsOnlyUsedAsTypes: true}]; continue; } @@ -220,12 +173,8 @@ function fixRules(rules = {}, isTypeScriptBlock = false) { return out; } -/** - * Deep-merges the `import/*` settings of every ESLint block, as ESLint does for the files that match them. - * Oxlint has no per-override settings and its own import rules ignore these, but the hosted eslint-plugin-import - * rules read them. `import/parsers` is left out: Oxlint parses every file itself. - * @returns {Record} - */ +// The hosted eslint-plugin-import rules read these settings. Oxlint has no per-override settings, so every block's +// values are merged into one. `import/parsers` is left out because Oxlint parses every file itself. function importSettings() { const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); const merge = (target, source) => { @@ -242,13 +191,9 @@ function importSettings() { return settings; } -/** - * Collects the jsPlugins migrate put inside overrides, which Oxlint only accepts at the root. - * @returns {Array<{name: string, specifier: string}>} - */ +// migrate puts jsPlugins inside overrides, but Oxlint accepts them only at the root. function hoistedJsPlugins() { const specifiers = new Set([migrated, ...(migrated.overrides ?? [])].flatMap((block) => block.jsPlugins ?? [])); - // eslint-plugin-expensify (rulesdir) rules are handled by fixRules. specifiers.delete('eslint-plugin-rulesdir'); return [...specifiers].map((specifier) => ({ name: specifier.replace(/^eslint-plugin-/, ''), @@ -256,18 +201,33 @@ function hoistedJsPlugins() { })); } -/** - * Replays the source config for the rules that run as JS plugins, in ESLint block order. - * @returns {{rules: Record, overrides: Array<{files: string[], rules: Record}>}} - */ +// When a later block sets only a severity, ESLint keeps the earlier options but Oxlint resets them to the +// defaults, so the earlier options are written out again. function replayJsPluginRules() { const rules = {}; const overrides = []; const scopedRules = new Set(); + const lastOptions = new Map(); + const filesKey = (files) => JSON.stringify(files ? [...files].sort() : null); + const withEarlierOptions = (block, target, entry) => { + const [severity, ...options] = Array.isArray(entry) ? entry : [entry]; + if (options.length) { + lastOptions.set(target, {files: filesKey(block.files), options}); + return entry; + } + const earlier = lastOptions.get(target); + if (!earlier || ['off', 0].includes(severity)) { + return entry; + } + if (earlier.files !== filesKey(null) && earlier.files !== filesKey(block.files)) { + throw new Error(`${target} gets options for some files and a bare severity for others; Oxlint cannot express ESLint's merge`); + } + return [severity, ...earlier.options]; + }; for (const block of eslintConfig) { const hits = Object.entries(block.rules ?? {}).flatMap(([ruleId, entry]) => { const target = jsPluginRuleFor(ruleId); - return target ? [[target, entry]] : []; + return target ? [[target, withEarlierOptions(block, target, entry)]] : []; }); if (!hits.length) { continue; @@ -309,7 +269,7 @@ const oxlintConfig = { settings: { ...migrated.settings, ...importSettings(), - // ESLint detects the version; Oxlint needs it spelled out. + // ESLint detects the version; Oxlint needs it set. react: {...migrated.settings?.react, version: require(resolvePackage('react/package.json')).version}, }, ignorePatterns: migrated.ignorePatterns, diff --git a/config/oxlint/plugins/core-rules.mjs b/config/oxlint/plugins/core-rules.mjs index d8fda446b..57aa17597 100644 --- a/config/oxlint/plugins/core-rules.mjs +++ b/config/oxlint/plugins/core-rules.mjs @@ -1,11 +1,4 @@ -/** - * ESLint core rules that Oxlint has no native (non-nursery) version of. They run inside Oxlint as a JS - * plugin under the `core/` prefix, using ESLint's own rule code, and respect `eslint-disable` comments - * written with the ESLint rule id. config/oxlint/derive.mjs picks the rules and the files each one runs on. - * Adapted from Expensify/App config/oxlint/plugins/core-rules.mjs. - * - * no-invalid-this is left out (see NOT_PORTED in derive.mjs). - */ +// Oxlint has no native version of these ESLint core rules, so it runs ESLint's own code for them. import {builtinRules} from 'eslint/use-at-your-own-risk'; import {coreRuleNames} from '../derive.mjs'; diff --git a/config/oxlint/plugins/hosted-rules.mjs b/config/oxlint/plugins/hosted-rules.mjs index 991af0b54..5d0817b15 100644 --- a/config/oxlint/plugins/hosted-rules.mjs +++ b/config/oxlint/plugins/hosted-rules.mjs @@ -1,28 +1,20 @@ -/** - * Runs rules from ESLint plugins (typescript-eslint, import, jsdoc, react) inside Oxlint, using the plugins' - * own code, for rules Oxlint has no native version of. Oxlint reserves those plugin names for its own - * ports, so all of them load under `hosted`. config/oxlint/derive.mjs picks the rules. Adapted from Expensify/App config/oxlint/plugins/hosted-rules.mjs. - */ +// Oxlint reserves the react, import, jsdoc and typescript plugin names for its own ports, so the ESLint versions +// of rules it lacks load here under `hosted`. import {STUBBED_PARSER_SERVICES, hostedRuleIds, pluginRules, splitRuleId} from '../derive.mjs'; -// Type-checked rules such as naming-convention ask for parser services, which Oxlint 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. Only rules listed in STUBBED_PARSER_SERVICES get this, because they never reach the type checker. +// Oxlint gives JS plugins no TypeScript program. typescript-eslint's getParserServices(context, true) accepts a +// missing program but still needs the node maps and reads context.languageOptions, so both are stubbed. const STUB_PARSER_SERVICES = { esTreeNodeToTSNodeMap: new Map(), tsNodeToESTreeNodeMap: new Map(), }; -/** - * @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 read-only and non-configurable. + // Not a Proxy: Oxlint makes `parserServices` read-only and non-configurable. const stubbedSourceCode = Object.create(sourceCode, { parserServices: {value: STUB_PARSER_SERVICES}, }); diff --git a/config/oxlint/sourceConfig.mjs b/config/oxlint/sourceConfig.mjs index 80ee88de8..50052d58c 100644 --- a/config/oxlint/sourceConfig.mjs +++ b/config/oxlint/sourceConfig.mjs @@ -1,11 +1,5 @@ -/** - * Source of the Oxlint config: the eslint-config-expensify presets plus Onyx's own blocks, in ESLint flat-config - * format. config/oxlint/derive.mjs converts it with @oxlint/migrate every time Oxlint loads, so bumping - * eslint-config-expensify updates Oxlint without editing any rule by hand. Onyx-specific changes go here. - * - * It does not need eslint.config.mjs. Until ESLint is removed, keep the Onyx blocks below in sync with it - * (the lint-parity comparison shows any drift). - */ +// Oxlint's source config, in ESLint format so @oxlint/migrate can convert it. It copies eslint.config.mjs without +// the rulesdir setup, so Oxlint keeps working once ESLint is removed. Until then, keep the two in sync. import browserConfig from 'eslint-config-expensify/browser'; import jestConfig from 'eslint-config-expensify/jest'; import reactConfig from 'eslint-config-expensify/react'; @@ -15,7 +9,7 @@ import prettierConfig from 'eslint-config-prettier'; import {defineConfig, globalIgnores} from 'eslint/config'; import globals from 'globals'; -import {downgradeDebtRules} from '../lintDebt.mjs'; +import downgradeDebtRules from '../lintDebt.mjs'; const configs = defineConfig([ globalIgnores([ @@ -61,8 +55,8 @@ const configs = defineConfig([ globals: globals.node, }, }, - // Flat config replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript - // and adds objectLiteralProperty exceptions for Onyx key strings (underscores) and numeric collection IDs. + // A later block replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript + // and allows Onyx key strings (underscores) and numeric collection IDs as object keys. { files: ['tests/**/*'], rules: { @@ -145,6 +139,4 @@ const configs = defineConfig([ prettierConfig, ]); -// Count-threshold gate: rules with existing violations report as warnings, and `npm run lint:oxlint` fails above -// the warning count it passes to --max-warnings. See config/lintDebt.mjs. export default configs.map((entry) => (entry.rules ? {...entry, rules: downgradeDebtRules(entry.rules)} : entry)); diff --git a/eslint.config.mjs b/eslint.config.mjs index 37d4fe7f5..705f807f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,8 +10,8 @@ import globals from 'globals'; import {createRequire} from 'node:module'; import path from 'node:path'; -// oxlint-disable-next-line import/extensions -- Oxlint's port flags this; eslint-plugin-import skips paths its resolver cannot resolve without the extension. -import {downgradeDebtRules} from './config/lintDebt.mjs'; +// oxlint-disable-next-line import/extensions -- Oxlint's port reports this import; ESLint's skips it because its resolver cannot resolve the path. +import downgradeDebtRules from './config/lintDebt.mjs'; const require = createRequire(import.meta.url); const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); @@ -66,8 +66,8 @@ const configs = defineConfig([ globals: globals.node, }, }, - // Flat config replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript - // and adds objectLiteralProperty exceptions for Onyx key strings (underscores) and numeric collection IDs. + // A later block replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript + // and allows Onyx key strings (underscores) and numeric collection IDs as object keys. { files: ['tests/**/*'], rules: { @@ -150,6 +150,4 @@ const configs = defineConfig([ prettierConfig, ]); -// Count-threshold gate: rules with existing violations report as warnings, and `npm run lint` fails above -// the warning count it passes to --max-warnings. See config/lintDebt.mjs. export default configs.map((entry) => (entry.rules ? {...entry, rules: downgradeDebtRules(entry.rules)} : entry)); diff --git a/oxlint.config.mts b/oxlint.config.mts index 529df9ad1..5921bd42b 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -1,22 +1 @@ -/** - * Oxlint config for react-native-onyx. No rule is written by hand: config/oxlint/derive.mjs converts the - * eslint-config-expensify presets plus Onyx's own blocks (config/oxlint/sourceConfig.mjs) with @oxlint/migrate - * each time Oxlint loads, so bumping eslint-config-expensify updates Oxlint. It does not read eslint.config.mjs. - * - * Rules Oxlint has no native version of run through JS plugins (see config/oxlint/plugins): - * - core/*: ESLint core rules, with ESLint's own code. - * - hosted/*: rules from ESLint plugins (typescript-eslint naming-convention, import, jsdoc, react). - * - es/*: eslint-plugin-es, loaded as is (JS files only). - * - * Rules Oxlint cannot run at all are listed with the reason in NOT_PORTED and STAND_INS in derive.mjs. - * Severities already include the warning gate in config/lintDebt.mjs. - */ -import {defineConfig} from 'oxlint'; -import type {OxlintConfig} from 'oxlint'; - -import {oxlintConfig} from './config/oxlint/derive.mjs'; - -// Annotated because the inferred type is too large for TypeScript to serialize (declaration: true). -const config: OxlintConfig = defineConfig(oxlintConfig); - -export default config; +export {oxlintConfig as default} from './config/oxlint/derive.mjs'; From 89a3eda224c0bf8aa43f90e9947f6cc872fcd83a Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 11:11:19 +0100 Subject: [PATCH 11/43] Reword Oxlint config comments in plain language and drop local-only lint-parity ignore --- config/oxlint/derive.mjs | 62 +++++++++++++------------- config/oxlint/plugins/hosted-rules.mjs | 11 ++--- config/oxlint/sourceConfig.mjs | 5 +-- eslint.config.mjs | 1 - 4 files changed, 38 insertions(+), 41 deletions(-) diff --git a/config/oxlint/derive.mjs b/config/oxlint/derive.mjs index 1e9ee0ce3..c441dca88 100644 --- a/config/oxlint/derive.mjs +++ b/config/oxlint/derive.mjs @@ -1,8 +1,8 @@ -// Builds the Oxlint config from sourceConfig.mjs on every run, so eslint-config-expensify updates need no hand edits. -// @oxlint/migrate converts the rules Oxlint has natively. Rules it lacks run on ESLint's own code through ./plugins, -// with severities and options copied from the source config. A rule Oxlint cannot run at all must be listed in -// NOT_PORTED or STAND_INS, or loading fails, so a new preset rule is never dropped silently. -// Oxlint loads the config and the plugins in one Node process, so the plugins import their rule lists from here. +// Oxlint's config, generated from sourceConfig.mjs each time Oxlint runs, so updating eslint-config-expensify needs no +// manual changes. @oxlint/migrate turns each ESLint rule into Oxlint's built-in version. Rules Oxlint has no built-in +// version of run with ESLint's own code through the plugins in ./plugins, with the same settings as in ESLint. +// A rule that cannot run either way must be listed in NOT_PORTED or STAND_INS, otherwise Oxlint stops with an error, +// so no rule is dropped without a decision. The plugin files read their rule lists from this module. import migrate from '@oxlint/migrate'; import {builtinRules} from 'eslint/use-at-your-own-risk'; import {createRequire} from 'node:module'; @@ -15,12 +15,12 @@ const require = createRequire(import.meta.url); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify', {paths: [repoRoot]})); -// Plugins come in through eslint-config-expensify, not as Onyx dependencies. +// The ESLint plugins are dependencies of eslint-config-expensify, not of Onyx, so look them up from there first. function resolvePackage(request) { return require.resolve(request, {paths: [expensifyConfigDirectory, repoRoot]}); } -// Oxlint reserves these plugin names for its own ports, so their missing rules run under `hosted`. +// Oxlint has built-in plugins with these names, so ESLint's versions of rules it lacks load as `hosted/`. const HOSTABLE_PLUGINS = { react: 'eslint-plugin-react', import: 'eslint-plugin-import', @@ -29,12 +29,12 @@ const HOSTABLE_PLUGINS = { }; const ALWAYS_HOSTED = new Set([ - // The native port ignores `export type {...}`, which ESLint reports (tests/types.ts). + // Oxlint's built-in version skips `export type {...}`, which ESLint reports (tests/types.ts). 'import/prefer-default-export', ]); -// Type-checked rules that work with stubbed parser services (hosted-rules.mjs). This holds while Onyx's -// naming-convention options use no `types` selector, which would need the type checker. +// typescript-eslint rules that ask for type information, which Oxlint plugins do not get. naming-convention works +// with empty placeholders instead (hosted-rules.mjs) as long as Onyx's options do not use the `types` selector. const STUBBED_PARSER_SERVICES = new Set(['@typescript-eslint/naming-convention']); const NOT_PORTED = { @@ -48,26 +48,21 @@ const NOT_PORTED = { 'rulesdir/boolean-conditional-rendering': 'needs type information, and Oxlint has no equivalent', }; -// Native rules that replace ESLint rules Oxlint cannot run. unicorn/prefer-at keeps its defaults: `checkAllIndexAccess` -// adds hundreds of reports on tuple access that the type-aware ESLint rule skips. +// Oxlint built-in rules used instead of ESLint rules it cannot run. unicorn/prefer-at keeps its default options: with +// `checkAllIndexAccess` it reports hundreds of tuple reads like `result[0]` that the ESLint rule allows. const STAND_INS = { 'unicorn/prefer-at': 'rulesdir/prefer-at', }; const STAND_IN_FOR = Object.fromEntries(Object.entries(STAND_INS).map(([oxlintRule, eslintRule]) => [eslintRule, oxlintRule])); -// Oxlint rejects the options migrate copies for these rules. +// Oxlint's versions of these rules take no options and fail to load with ESLint's. const DROP_OPTIONS = new Set(['react/jsx-no-duplicate-props', 'react/jsx-no-undef']); const TYPESCRIPT_GLOB = /\.(\{[^}]*\b)?[cm]?tsx?\b/; -const pluginRulesCache = new Map(); - function pluginRules(prefix) { - if (!pluginRulesCache.has(prefix)) { - const mod = require(resolvePackage(HOSTABLE_PLUGINS[prefix])); - pluginRulesCache.set(prefix, mod?.rules ?? mod?.default?.rules ?? {}); - } - return pluginRulesCache.get(prefix); + const plugin = require(resolvePackage(HOSTABLE_PLUGINS[prefix])); + return plugin.rules ?? plugin.default?.rules ?? {}; } function splitRuleId(ruleId) { @@ -75,8 +70,9 @@ function splitRuleId(ruleId) { return slash === -1 ? {prefix: '', name: ruleId} : {prefix: ruleId.slice(0, slash), name: ruleId.slice(slash + 1)}; } -// The non-type-aware pass finds core rules that migrate swaps for a type-aware typescript/* rule (dot-notation). -// tsgolint only checks files in the TypeScript program, so JS files still need the core rule. +// migrate runs twice. With type information on, it replaces ESLint core rules like dot-notation with their +// typescript-eslint versions, which the presets switch off for JS files, where ESLint uses the core rule. The run +// without type information reports those core rules as missing in Oxlint, so they run from ESLint's code. async function runMigrate() { const skipped = new Set(); const run = async (typeAware) => { @@ -122,7 +118,7 @@ for (const ruleId of [...skipped, ...ALWAYS_HOSTED]) { throw new Error(`Oxlint cannot run ${ruleId} natively or as a JS plugin. Add it to NOT_PORTED or STAND_INS in config/oxlint/derive.mjs.`); } -// Hosted rules share one namespace, so two plugins must not bring the same rule name. +// All hosted rules share the `hosted/` prefix, so two plugins cannot both bring a rule with the same name. const hostedNames = hostedRuleIds.map((ruleId) => splitRuleId(ruleId).name); const duplicateName = hostedNames.find((name, index) => hostedNames.indexOf(name) !== index); if (duplicateName) { @@ -145,7 +141,7 @@ function fixRules(rules = {}, isTypeScriptBlock = false) { continue; } if (rule.startsWith('rulesdir/')) { - // eslint-plugin-expensify rules need type information, which Oxlint JS plugins do not get. + // eslint-plugin-expensify (rulesdir) rules need type information, which Oxlint plugins do not get. if (STAND_IN_FOR[rule]) { out[STAND_IN_FOR[rule]] = severityOf(entry); } else if (severityOf(entry) !== 'off' && !NOT_PORTED[rule]) { @@ -158,13 +154,15 @@ function fixRules(rules = {}, isTypeScriptBlock = false) { continue; } if (rule === 'import/no-cycle' && Array.isArray(entry)) { - // Oxlint rejects ESLint's maxDepth "∞"; its own default is already unlimited. + // The preset sets maxDepth to the string "∞" (no limit). Oxlint accepts only a number and has no limit by + // default, so the option is dropped. const {maxDepth, ...options} = entry[1] ?? {}; out[rule] = maxDepth === '∞' ? [entry[0], options] : entry; continue; } if (rule === 'no-unused-vars' && isTypeScriptBlock && Array.isArray(entry)) { - // typescript-eslint reports values used only as types; Oxlint does so only with this option. + // On TS files ESLint uses typescript-eslint's no-unused-vars, which also reports values used only as types. + // Oxlint's rule does that only with this option. out[rule] = [entry[0], {...entry[1], reportVarsOnlyUsedAsTypes: true}]; continue; } @@ -173,8 +171,8 @@ function fixRules(rules = {}, isTypeScriptBlock = false) { return out; } -// The hosted eslint-plugin-import rules read these settings. Oxlint has no per-override settings, so every block's -// values are merged into one. `import/parsers` is left out because Oxlint parses every file itself. +// ESLint's import rules (run as hosted) read these settings. Oxlint cannot set settings per file pattern, so all +// blocks are merged into one. `import/parsers` is skipped because Oxlint parses the files itself. function importSettings() { const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); const merge = (target, source) => { @@ -191,7 +189,7 @@ function importSettings() { return settings; } -// migrate puts jsPlugins inside overrides, but Oxlint accepts them only at the root. +// migrate adds jsPlugins inside file-pattern overrides, but Oxlint allows them only at the top level. function hoistedJsPlugins() { const specifiers = new Set([migrated, ...(migrated.overrides ?? [])].flatMap((block) => block.jsPlugins ?? [])); specifiers.delete('eslint-plugin-rulesdir'); @@ -201,8 +199,8 @@ function hoistedJsPlugins() { })); } -// When a later block sets only a severity, ESLint keeps the earlier options but Oxlint resets them to the -// defaults, so the earlier options are written out again. +// When a later block sets only a severity (like `'warn'`), ESLint keeps the options from the earlier block and +// Oxlint drops them, so the earlier options are copied in. function replayJsPluginRules() { const rules = {}; const overrides = []; @@ -269,7 +267,7 @@ const oxlintConfig = { settings: { ...migrated.settings, ...importSettings(), - // ESLint detects the version; Oxlint needs it set. + // ESLint finds the React version itself; Oxlint needs it set. react: {...migrated.settings?.react, version: require(resolvePackage('react/package.json')).version}, }, ignorePatterns: migrated.ignorePatterns, diff --git a/config/oxlint/plugins/hosted-rules.mjs b/config/oxlint/plugins/hosted-rules.mjs index 5d0817b15..f801ba6f2 100644 --- a/config/oxlint/plugins/hosted-rules.mjs +++ b/config/oxlint/plugins/hosted-rules.mjs @@ -1,9 +1,10 @@ -// Oxlint reserves the react, import, jsdoc and typescript plugin names for its own ports, so the ESLint versions -// of rules it lacks load here under `hosted`. +// Oxlint has built-in plugins named react, import, jsdoc and typescript, so ESLint's versions of rules Oxlint lacks +// load here under the name `hosted`. import {STUBBED_PARSER_SERVICES, hostedRuleIds, pluginRules, splitRuleId} from '../derive.mjs'; -// Oxlint gives JS plugins no TypeScript program. typescript-eslint's getParserServices(context, true) accepts a -// missing program but still needs the node maps and reads context.languageOptions, so both are stubbed. +// typescript-eslint rules call getParserServices, which needs type information Oxlint plugins do not get. Called as +// getParserServices(context, true) it accepts a missing TypeScript program, but still reads these two maps and +// context.languageOptions, so they get empty placeholders. const STUB_PARSER_SERVICES = { esTreeNodeToTSNodeMap: new Map(), tsNodeToESTreeNodeMap: new Map(), @@ -14,7 +15,7 @@ function withStubbedParserServices(rule) { ...rule, create(context) { const sourceCode = context.sourceCode ?? context.getSourceCode(); - // Not a Proxy: Oxlint makes `parserServices` read-only and non-configurable. + // Oxlint makes `parserServices` read-only, so a Proxy cannot replace it; a new object built on top can. const stubbedSourceCode = Object.create(sourceCode, { parserServices: {value: STUB_PARSER_SERVICES}, }); diff --git a/config/oxlint/sourceConfig.mjs b/config/oxlint/sourceConfig.mjs index 50052d58c..adeb50f26 100644 --- a/config/oxlint/sourceConfig.mjs +++ b/config/oxlint/sourceConfig.mjs @@ -1,5 +1,5 @@ -// Oxlint's source config, in ESLint format so @oxlint/migrate can convert it. It copies eslint.config.mjs without -// the rulesdir setup, so Oxlint keeps working once ESLint is removed. Until then, keep the two in sync. +// Oxlint's config is generated from this file (see derive.mjs). It is a copy of eslint.config.mjs without the +// rulesdir setup, so Oxlint keeps working after eslint.config.mjs is deleted. Until then, change both files together. import browserConfig from 'eslint-config-expensify/browser'; import jestConfig from 'eslint-config-expensify/jest'; import reactConfig from 'eslint-config-expensify/react'; @@ -22,7 +22,6 @@ const configs = defineConfig([ 'cpp/**', 'bench-results/**', '.github/**', - 'lint-parity/**', 'oxlint.config.mts', 'config/oxlint/**', ]), diff --git a/eslint.config.mjs b/eslint.config.mjs index 705f807f7..0837b470f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,7 +28,6 @@ const configs = defineConfig([ 'cpp/**', 'bench-results/**', '.github/**', - 'lint-parity/**', 'oxlint.config.mts', 'config/oxlint/**', ]), From 415b8c73d1e40555ba0d1559b4e3ff33861732de Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 11:14:52 +0100 Subject: [PATCH 12/43] Make Oxlint the main lint script and keep ESLint as lint:eslint --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index bcd9d01cf..30b46798d 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "types": "dist/index.d.ts", "scripts": { "prepare": "npm run build", - "lint": "eslint . --max-warnings 713", - "lint:oxlint": "oxlint --max-warnings 665", + "lint": "oxlint --type-aware --max-warnings 665", + "lint:eslint": "eslint . --max-warnings 713", "typecheck": "tsc --noEmit", "test": "jest", "test:types": "npm run build && tsc --noEmit --project tsconfig.test.json", @@ -39,7 +39,7 @@ "build:watch": "nodemon --watch lib --ext js,json,ts,tsx --exec \"npm run build && npm pack\"", "prebuild:docs": "npm run build", "build:docs": "ts-node buildDocs.ts", - "lint-tests": "eslint tests/**", + "lint-tests": "oxlint --type-aware tests", "prettier": "prettier --write .", "gh-actions-build": "./.github/scripts/buildActions.sh" }, From f37985870f7761432134247a92dd80c5455367cd Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 12:35:11 +0100 Subject: [PATCH 13/43] Replace Oxlint max-warnings gate with an App-style seatbelt that auto-tightens on main --- .github/workflows/lint.yml | 49 +++- .github/workflows/test.yml | 4 +- config/lintDebt.mjs | 6 +- config/oxlint/lint.mjs | 138 ++++++++++ config/oxlint/oxlint.seatbelt.tsv | 140 ++++++++++ config/oxlint/seatbelt.mjs | 431 ++++++++++++++++++++++++++++++ config/oxlint/seatbelt.test.mjs | 232 ++++++++++++++++ config/oxlint/sourceConfig.mjs | 7 +- package.json | 5 +- 9 files changed, 997 insertions(+), 15 deletions(-) create mode 100644 config/oxlint/lint.mjs create mode 100644 config/oxlint/oxlint.seatbelt.tsv create mode 100644 config/oxlint/seatbelt.mjs create mode 100644 config/oxlint/seatbelt.test.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9226ca617..142b28701 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,9 +3,13 @@ name: Lint JavaScript on: pull_request: types: [opened, synchronize] + push: + branches: [main] jobs: lint: + # os-botify commits version bumps and seatbelt tightening to main; those commits need no lint run. + if: ${{ github.actor != 'os-botify[bot]' }} runs-on: ubuntu-latest steps: # v5.0.0 @@ -21,10 +25,7 @@ jobs: - run: npm ci - - run: npm run lint - env: - CI: true - + # Runs before lint, which may rewrite the seatbelt file and would fail this diff check. - name: Verify there's no Prettier diff run: | npm run prettier -- --loglevel silent @@ -33,3 +34,43 @@ jobs: echo 'Error: Prettier diff detected! Please run `npm run prettier` and commit the changes.' exit 1 fi + + - run: npm run lint + env: + CI: true + + # On main, lint rewrites the seatbelt file when merged PRs fixed grandfathered errors. os-botify commits it + # back, through the same GitHub App token and contents API the publish workflow uses for version bumps. + - name: Check for tightened seatbelt baseline + id: seatbelt + if: github.event_name == 'push' + run: | + if git diff --quiet config/oxlint/oxlint.seatbelt.tsv; then + echo "tightened=false" >> "$GITHUB_OUTPUT" + echo "::notice::oxlint.seatbelt.tsv unchanged; skipping auto-commit." + else + echo "tightened=true" >> "$GITHUB_OUTPUT" + fi + + - name: Generate os-botify token + id: token + if: steps.seatbelt.outputs.tightened == 'true' + uses: actions/create-github-app-token@9d97a4282b2c51a2f4f0465b9326399f53c890d4 + with: + app-id: ${{ secrets.OS_BOTIFY_APP_ID }} + private-key: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} + + # If another commit changed the file meanwhile, the API rejects the stale sha. The next push to main + # tightens again, so this step is allowed to fail. + - name: Auto-commit tightened seatbelt baseline + if: steps.seatbelt.outputs.tightened == 'true' + continue-on-error: true + run: | + file=config/oxlint/oxlint.seatbelt.tsv + gh api --method PUT "/repos/${{ github.repository }}/contents/$file" \ + --field message="Auto-tighten oxlint seatbelt baseline" \ + --field content="$(base64 -w0 "$file")" \ + --field branch=main \ + --field sha="$(git rev-parse "HEAD:$file")" + env: + GITHUB_TOKEN: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de38cf64a..527c38404 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,4 +31,6 @@ jobs: env: CI: true - - run: npm run test:types \ No newline at end of file + - run: npm run test:types + + - run: npm run test:lint \ No newline at end of file diff --git a/config/lintDebt.mjs b/config/lintDebt.mjs index 233537068..2056c2566 100644 --- a/config/lintDebt.mjs +++ b/config/lintDebt.mjs @@ -1,6 +1,6 @@ -// Rules that already had violations when eslint-config-expensify 4.x was adopted report as warnings instead of -// errors, and the `lint` and `lint:oxlint` scripts fail above the current warning count. Remove a rule here, and -// lower the counts, once it reaches zero. +// ESLint only: rules that already had violations when eslint-config-expensify 4.x was adopted report as warnings +// instead of errors, and `npm run lint:eslint` fails above the current warning count. Oxlint uses the seatbelt +// (config/oxlint/seatbelt.mjs) instead. Remove a rule here, and lower the count, once it reaches zero. const LINT_DEBT_RULES = new Set([ '@typescript-eslint/consistent-type-definitions', '@typescript-eslint/naming-convention', diff --git a/config/oxlint/lint.mjs b/config/oxlint/lint.mjs new file mode 100644 index 000000000..a8163eef3 --- /dev/null +++ b/config/oxlint/lint.mjs @@ -0,0 +1,138 @@ +// Runs Oxlint, then the seatbelt ratchet (seatbelt.mjs), then prints the result. Ported from Expensify/App +// scripts/lint/ without App's sharding, React Compiler filter and no-deprecated stratification. +// +// npm run lint lint the whole repo +// npm run lint -- lib/Onyx.ts tests lint the given paths +// npm run lint -- --show-warnings also print the grandfathered warnings +// npm run lint -- --format=json print a JSON report +// npm run lint -- --fix apply Oxlint's auto-fixes +import {spawnSync} from 'node:child_process'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {parseArgs} from 'node:util'; + +import {LINT_SEVERITY, applySeatbelt, resolveSeatbeltOptions} from './seatbelt.mjs'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +// Called directly rather than through npx, which passes the whole command line to `sh -c` as one string. +const OXLINT_BIN = path.join(projectRoot, 'node_modules', '.bin', 'oxlint'); +const FATAL_EXIT_CODE = 2; +const JS_PLUGIN_CRASH_MESSAGE = 'Error running JS plugin'; + +function fail(reason, output = '') { + console.error([reason, output.trim().slice(0, 2000)].filter(Boolean).join('\n')); + process.exit(FATAL_EXIT_CODE); +} + +function runOxlint(args) { + const result = spawnSync(OXLINT_BIN, args, {cwd: projectRoot, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024}); + if (result.error) { + fail(`Could not run ${OXLINT_BIN}: ${result.error.message}`); + } + return {stdout: result.stdout ?? '', stderr: result.stderr ?? '', exitCode: result.status ?? FATAL_EXIT_CODE}; +} + +// Oxlint's JSON report leaves out files without findings, and the seatbelt needs them to notice fixed errors. +function listLintedFiles(targets) { + return runOxlint(['--debug=files', ...targets]) + .stdout.split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((file) => path.resolve(projectRoot, file)); +} + +// `typescript(no-explicit-any)` becomes `typescript/no-explicit-any` and `eslint(no-undef)` becomes `no-undef`, +// the names oxlint.config uses, so baseline rows can be matched to config and disable comments by name. +function toRuleID(code) { + const match = /^([\w@/.-]+)\((.+)\)$/.exec(code); + if (!match) { + return code; + } + return match[1] === 'eslint' ? match[2] : `${match[1]}/${match[2]}`; +} + +function toLintMessage(diagnostic) { + const span = diagnostic.labels?.[0]?.span; + const text = [diagnostic.message, diagnostic.help, diagnostic.note].filter(Boolean).join('\n'); + return { + filePath: path.resolve(projectRoot, diagnostic.filename), + ruleID: diagnostic.code ? toRuleID(diagnostic.code) : null, + severity: ['warning', 'advice'].includes(diagnostic.severity) ? LINT_SEVERITY.WARNING : LINT_SEVERITY.ERROR, + // A diagnostic without a rule is a file Oxlint could not parse. + message: diagnostic.code ? text : `Parsing error: ${text}`, + line: span?.line ?? 0, + column: span?.column ?? 0, + }; +} + +function lint(targets, fix) { + const {stdout, stderr, exitCode} = runOxlint(['--type-aware', '--format', 'json', ...(fix ? ['--fix'] : []), ...targets]); + // Oxlint exits 1 for findings. Anything higher, or no JSON at all, means it crashed or rejected the config. + const start = stdout.indexOf('{'); + const end = stdout.lastIndexOf('}'); + if (exitCode > 1 || start < 0 || end <= start) { + fail(`Oxlint failed (exit ${exitCode}).`, `${stderr}\n${stdout}`); + } + let report; + try { + report = JSON.parse(stdout.slice(start, end + 1)); + } catch { + fail('Could not parse Oxlint JSON output.', stdout); + } + if (!Array.isArray(report?.diagnostics)) { + fail('Oxlint JSON output has no `diagnostics` array.', stdout); + } + // A JS plugin crash also has no rule, but no location either. It would hide every JS plugin finding in the + // file, so it fails the run instead of passing as a parse error. + const crashes = report.diagnostics.filter((diagnostic) => !diagnostic.code && (diagnostic.message.startsWith(JS_PLUGIN_CRASH_MESSAGE) || !diagnostic.labels?.length)); + if (crashes.length) { + fail( + `Oxlint reported ${crashes.length} JS plugin crash(es):`, + crashes + .slice(0, 5) + .map((diagnostic) => ` ${diagnostic.filename}: ${diagnostic.message.split('\n').slice(0, 3).join(' ')}`) + .join('\n'), + ); + } + return report.diagnostics.map(toLintMessage); +} + +function formatStylish(messages, showWarnings) { + const errorCount = messages.filter((message) => message.severity === LINT_SEVERITY.ERROR).length; + const warningCount = messages.length - errorCount; + const visible = showWarnings ? messages : messages.filter((message) => message.severity === LINT_SEVERITY.ERROR); + const lines = visible.map((message) => { + const location = `${path.relative(projectRoot, message.filePath)}:${message.line}:${message.column}`; + const level = message.severity === LINT_SEVERITY.ERROR ? 'error' : 'warning'; + return `${location}\n ${level} ${message.message}${message.ruleID ? ` ${message.ruleID}` : ''}`; + }); + const summary = `${errorCount} error${errorCount === 1 ? '' : 's'}${showWarnings ? `, ${warningCount} warning${warningCount === 1 ? '' : 's'}` : ''}`; + return {text: [...lines, summary].join('\n\n'), errorCount}; +} + +const {values, positionals} = parseArgs({ + allowPositionals: true, + options: { + 'show-warnings': {type: 'boolean', default: false}, + format: {type: 'string', default: 'stylish'}, + fix: {type: 'boolean', default: false}, + }, +}); +if (!['stylish', 'json'].includes(values.format)) { + fail(`Unknown format "${values.format}". Expected stylish or json.`); +} + +const targets = positionals.length ? positionals : ['.']; +const lintedFiles = listLintedFiles(targets); +if (!lintedFiles.length) { + fail(`Oxlint matched no files for: ${targets.join(' ')}`); +} + +const {messages} = applySeatbelt(lint(targets, values.fix), resolveSeatbeltOptions(projectRoot), lintedFiles); +const errorCount = messages.filter((message) => message.severity === LINT_SEVERITY.ERROR).length; +if (values.format === 'json') { + console.log(JSON.stringify({messages, errorCount, warningCount: messages.length - errorCount})); +} else { + console.log(formatStylish(messages, values['show-warnings']).text); +} +process.exitCode = errorCount > 0 ? 1 : 0; diff --git a/config/oxlint/oxlint.seatbelt.tsv b/config/oxlint/oxlint.seatbelt.tsv new file mode 100644 index 000000000..803e458de --- /dev/null +++ b/config/oxlint/oxlint.seatbelt.tsv @@ -0,0 +1,140 @@ +# seatbelt temporarily allowed errors +# docs: config/oxlint/seatbelt.mjs + +"../../buildDocs.ts" "hosted/no-extraneous-dependencies" 1 +"../../jest-test-environment.ts" "hosted/no-extraneous-dependencies" 1 +"../../lib/DevTools/RealDevTools.ts" "typescript/no-unsafe-assignment" 1 +"../../lib/DevTools/RealDevTools.ts" "typescript/no-unsafe-member-access" 1 +"../../lib/DevTools/RealDevTools.ts" "typescript/no-unsafe-type-assertion" 1 +"../../lib/Onyx.ts" "no-loop-func" 1 +"../../lib/Onyx.ts" "typescript/no-redundant-type-constituents" 3 +"../../lib/Onyx.ts" "typescript/no-unnecessary-type-assertion" 9 +"../../lib/Onyx.ts" "typescript/no-unsafe-type-assertion" 14 +"../../lib/Onyx.ts" "typescript/non-nullable-type-assertion-style" 1 +"../../lib/OnyxCache.ts" "typescript/no-redundant-type-constituents" 2 +"../../lib/OnyxKeys.ts" "typescript/no-unsafe-type-assertion" 1 +"../../lib/OnyxKeys.ts" "typescript/restrict-template-expressions" 1 +"../../lib/OnyxMerge/index.native.ts" "typescript/no-redundant-type-constituents" 2 +"../../lib/OnyxMerge/index.native.ts" "typescript/no-unsafe-type-assertion" 4 +"../../lib/OnyxMerge/index.ts" "typescript/no-redundant-type-constituents" 2 +"../../lib/OnyxMerge/index.ts" "typescript/no-unsafe-type-assertion" 2 +"../../lib/OnyxMerge/types.ts" "typescript/no-redundant-type-constituents" 2 +"../../lib/OnyxSubscriptionManager.ts" "typescript/no-unsafe-type-assertion" 4 +"../../lib/OnyxSubscriptionManager.ts" "typescript/restrict-template-expressions" 1 +"../../lib/OnyxUtils.ts" "no-use-before-define" 28 +"../../lib/OnyxUtils.ts" "typescript/no-redundant-type-constituents" 7 +"../../lib/OnyxUtils.ts" "typescript/no-unnecessary-type-assertion" 2 +"../../lib/OnyxUtils.ts" "typescript/no-unsafe-argument" 5 +"../../lib/OnyxUtils.ts" "typescript/no-unsafe-type-assertion" 23 +"../../lib/OnyxUtils.ts" "typescript/non-nullable-type-assertion-style" 1 +"../../lib/OnyxUtils.ts" "typescript/prefer-nullish-coalescing" 3 +"../../lib/StateMachine.ts" "typescript/no-unsafe-type-assertion" 2 +"../../lib/storage/__mocks__/index.ts" "typescript/no-unsafe-argument" 1 +"../../lib/storage/InstanceSync/index.ts" "no-unused-vars" 1 +"../../lib/storage/InstanceSync/index.web.ts" "hosted/no-types" 1 +"../../lib/storage/InstanceSync/index.web.ts" "typescript/no-unsafe-type-assertion" 1 +"../../lib/storage/InstanceSync/index.web.ts" "typescript/restrict-template-expressions" 1 +"../../lib/storage/providers/IDBKeyValProvider/index.ts" "typescript/no-unsafe-return" 1 +"../../lib/storage/providers/IDBKeyValProvider/index.ts" "typescript/no-unsafe-type-assertion" 3 +"../../lib/storage/providers/MemoryOnlyProvider.ts" "typescript/no-unsafe-type-assertion" 5 +"../../lib/storage/providers/NoopProvider.ts" "no-unused-vars" 1 +"../../lib/storage/providers/NoopProvider.ts" "typescript/no-unsafe-type-assertion" 1 +"../../lib/storage/providers/SQLiteProvider.ts" "hosted/naming-convention" 4 +"../../lib/storage/providers/SQLiteProvider.ts" "typescript/no-unsafe-assignment" 1 +"../../lib/storage/providers/SQLiteProvider.ts" "typescript/no-unsafe-return" 2 +"../../lib/storage/providers/SQLiteProvider.ts" "typescript/no-unsafe-type-assertion" 3 +"../../lib/storage/providers/SQLiteProvider.ts" "unicorn/prefer-at" 1 +"../../lib/Str.ts" "typescript/no-unsafe-type-assertion" 1 +"../../lib/types.ts" "typescript/consistent-type-definitions" 1 +"../../lib/types.ts" "typescript/no-empty-object-type" 1 +"../../lib/types.ts" "typescript/no-restricted-types" 2 +"../../lib/useOnyx.ts" "typescript/no-unsafe-type-assertion" 2 +"../../lib/utils.ts" "hosted/naming-convention" 2 +"../../lib/utils.ts" "typescript/no-redundant-type-constituents" 2 +"../../lib/utils.ts" "typescript/no-unsafe-type-assertion" 9 +"../../tests/perf-test/Onyx.perf-test.ts" "typescript/no-unnecessary-type-assertion" 2 +"../../tests/perf-test/Onyx.perf-test.ts" "typescript/no-unsafe-type-assertion" 1 +"../../tests/perf-test/Onyx.perf-test.ts" "typescript/restrict-template-expressions" 1 +"../../tests/perf-test/OnyxCache.perf-test.ts" "typescript/no-unnecessary-type-assertion" 1 +"../../tests/perf-test/OnyxCache.perf-test.ts" "typescript/no-unsafe-assignment" 1 +"../../tests/perf-test/OnyxCache.perf-test.ts" "typescript/no-unsafe-member-access" 1 +"../../tests/perf-test/OnyxCache.perf-test.ts" "typescript/no-unsafe-type-assertion" 1 +"../../tests/perf-test/OnyxCache.perf-test.ts" "typescript/non-nullable-type-assertion-style" 1 +"../../tests/perf-test/OnyxUtils.perf-test.ts" "typescript/no-misused-promises" 1 +"../../tests/perf-test/OnyxUtils.perf-test.ts" "typescript/no-unnecessary-type-assertion" 5 +"../../tests/perf-test/OnyxUtils.perf-test.ts" "typescript/no-unsafe-type-assertion" 1 +"../../tests/perf-test/useOnyx.perf-test.tsx" "typescript/no-unnecessary-type-assertion" 1 +"../../tests/perf-test/useOnyx.perf-test.tsx" "typescript/no-unsafe-type-assertion" 4 +"../../tests/perf-test/utils.perf-test.ts" "typescript/no-unsafe-call" 1 +"../../tests/perf-test/utils.perf-test.ts" "typescript/no-unsafe-member-access" 3 +"../../tests/unit/collectionHydrationTest.ts" "typescript/no-unsafe-assignment" 3 +"../../tests/unit/collectionHydrationTest.ts" "typescript/no-unsafe-member-access" 7 +"../../tests/unit/collectionHydrationTest.ts" "unicorn/prefer-at" 3 +"../../tests/unit/DevToolsTest.ts" "typescript/no-unnecessary-type-assertion" 1 +"../../tests/unit/DevToolsTest.ts" "typescript/no-unsafe-assignment" 2 +"../../tests/unit/DevToolsTest.ts" "typescript/no-unsafe-type-assertion" 9 +"../../tests/unit/onyxCacheTest.tsx" "typescript/no-non-null-assertion" 5 +"../../tests/unit/onyxCacheTest.tsx" "typescript/no-unsafe-assignment" 8 +"../../tests/unit/onyxCacheTest.tsx" "typescript/no-unsafe-member-access" 7 +"../../tests/unit/onyxCacheTest.tsx" "typescript/no-unsafe-type-assertion" 4 +"../../tests/unit/onyxClearConcurrentWriteTest.ts" "typescript/no-unsafe-assignment" 1 +"../../tests/unit/onyxClearConcurrentWriteTest.ts" "typescript/no-unsafe-member-access" 1 +"../../tests/unit/onyxClearConcurrentWriteTest.ts" "typescript/no-unsafe-type-assertion" 1 +"../../tests/unit/onyxClearNativeStorageTest.ts" "typescript/no-unsafe-assignment" 1 +"../../tests/unit/onyxClearNativeStorageTest.ts" "typescript/no-unsafe-member-access" 1 +"../../tests/unit/onyxClearNativeStorageTest.ts" "typescript/no-unsafe-type-assertion" 4 +"../../tests/unit/onyxClearWebStorageTest.ts" "typescript/no-unnecessary-type-assertion" 4 +"../../tests/unit/onyxClearWebStorageTest.ts" "typescript/no-unsafe-assignment" 1 +"../../tests/unit/onyxClearWebStorageTest.ts" "typescript/no-unsafe-member-access" 1 +"../../tests/unit/OnyxMergeNativeTest.ts" "typescript/no-unsafe-return" 3 +"../../tests/unit/onyxMultiMergeWebStorageTest.ts" "typescript/no-unnecessary-type-assertion" 5 +"../../tests/unit/onyxMultiMergeWebStorageTest.ts" "typescript/no-unsafe-assignment" 1 +"../../tests/unit/onyxMultiMergeWebStorageTest.ts" "typescript/no-unsafe-member-access" 1 +"../../tests/unit/onyxMultiMergeWebStorageTest.ts" "typescript/no-unsafe-type-assertion" 1 +"../../tests/unit/OnyxSubscriptionManagerTest.ts" "typescript/no-unnecessary-type-assertion" 1 +"../../tests/unit/OnyxSubscriptionManagerTest.ts" "typescript/no-unsafe-type-assertion" 1 +"../../tests/unit/onyxTest.ts" "hosted/no-extraneous-dependencies" 2 +"../../tests/unit/onyxTest.ts" "typescript/no-deprecated" 2 +"../../tests/unit/onyxTest.ts" "typescript/no-unnecessary-type-assertion" 67 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-assignment" 15 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-call" 6 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-member-access" 28 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-return" 1 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-type-assertion" 46 +"../../tests/unit/onyxUtilsTest.ts" "typescript/no-unnecessary-type-assertion" 21 +"../../tests/unit/onyxUtilsTest.ts" "typescript/no-unsafe-assignment" 9 +"../../tests/unit/onyxUtilsTest.ts" "typescript/no-unsafe-call" 2 +"../../tests/unit/onyxUtilsTest.ts" "typescript/no-unsafe-member-access" 20 +"../../tests/unit/onyxUtilsTest.ts" "typescript/no-unsafe-return" 1 +"../../tests/unit/onyxUtilsTest.ts" "typescript/no-unsafe-type-assertion" 4 +"../../tests/unit/storage/instanceSyncWebTest.ts" "typescript/no-unsafe-type-assertion" 4 +"../../tests/unit/storage/providers/createStoreTest.ts" "typescript/no-non-null-assertion" 1 +"../../tests/unit/storage/providers/createStoreTest.ts" "typescript/no-unnecessary-type-assertion" 4 +"../../tests/unit/storage/providers/createStoreTest.ts" "typescript/no-unsafe-assignment" 27 +"../../tests/unit/storage/providers/createStoreTest.ts" "typescript/no-unsafe-type-assertion" 11 +"../../tests/unit/storage/providers/createStoreTest.ts" "typescript/unbound-method" 15 +"../../tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "typescript/no-unnecessary-type-assertion" 2 +"../../tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "typescript/no-unsafe-member-access" 2 +"../../tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "typescript/no-unsafe-type-assertion" 4 +"../../tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "typescript/unbound-method" 2 +"../../tests/unit/storage/providers/SQLiteProviderTest.ts" "hosted/naming-convention" 3 +"../../tests/unit/storage/providers/SQLiteProviderTest.ts" "no-unused-vars" 1 +"../../tests/unit/storage/providers/SQLiteProviderTest.ts" "typescript/no-non-null-assertion" 8 +"../../tests/unit/storage/providers/SQLiteProviderTest.ts" "typescript/no-unnecessary-type-assertion" 7 +"../../tests/unit/storage/providers/SQLiteProviderTest.ts" "typescript/no-unsafe-member-access" 2 +"../../tests/unit/storage/providers/SQLiteProviderTest.ts" "typescript/no-unsafe-type-assertion" 5 +"../../tests/unit/storage/providers/SQLiteProviderTest.ts" "typescript/unbound-method" 5 +"../../tests/unit/storage/SQLiteStorageFallbackTest.ts" "typescript/no-unsafe-assignment" 1 +"../../tests/unit/storage/SQLiteStorageFallbackTest.ts" "typescript/no-unsafe-return" 1 +"../../tests/unit/storage/tryOrDegradePerformanceTest.ts" "typescript/no-unsafe-assignment" 3 +"../../tests/unit/storage/tryOrDegradePerformanceTest.ts" "typescript/no-unsafe-member-access" 2 +"../../tests/unit/storage/tryOrDegradePerformanceTest.ts" "typescript/no-unsafe-return" 1 +"../../tests/unit/storage/tryOrDegradePerformanceTest.ts" "typescript/no-unsafe-type-assertion" 1 +"../../tests/unit/useOnyxTest.ts" "typescript/no-unnecessary-type-assertion" 17 +"../../tests/unit/useOnyxTest.ts" "typescript/no-unsafe-type-assertion" 22 +"../../tests/unit/useOnyxTest.ts" "typescript/prefer-nullish-coalescing" 1 +"../../tests/unit/utilsTest.ts" "typescript/no-restricted-types" 1 +"../../tests/unit/utilsTest.ts" "typescript/no-unnecessary-type-assertion" 2 +"../../tests/unit/utilsTest.ts" "typescript/no-unsafe-member-access" 3 +"../../tests/unit/utilsTest.ts" "typescript/no-unsafe-type-assertion" 8 +"../../tests/utils/collections/reportActions.ts" "typescript/restrict-template-expressions" 1 diff --git a/config/oxlint/seatbelt.mjs b/config/oxlint/seatbelt.mjs new file mode 100644 index 000000000..dcee66221 --- /dev/null +++ b/config/oxlint/seatbelt.mjs @@ -0,0 +1,431 @@ +// Seatbelt ratchet for Oxlint, ported from Expensify/App scripts/lint/processors/Seatbelt.ts (which follows +// justjake/eslint-seatbelt). config/oxlint/oxlint.seatbelt.tsv records, per file and rule, how many errors are +// allowed. Errors up to that count report as warnings; one more fails the run. Counts can only go down, unless +// SEATBELT_INCREASE names the rule. +// +// As in App, local runs only read the file, so fixing errors leaves the working tree clean. CI writes it, and the +// lint job on main commits the tightened file back as os-botify (.github/workflows/lint.yml). +// +// Env: SEATBELT_INCREASE= allows and records an increase (commit the file with your change), +// SEATBELT_READ_ONLY=0 writes locally, SEATBELT_FROZEN=1 fails on any difference from the file, SEATBELT_KEEP= +// keeps rows that reached zero, SEATBELT_DISABLE=1 turns it off, SEATBELT_QUIET=1 hides the grandfathered warnings, +// SEATBELT_VERBOSE=1 logs every decision. +import fs from 'node:fs'; +import path from 'node:path'; + +const SEATBELT_NAME = 'seatbelt'; +const SEATBELT_TSV_RELATIVE = 'config/oxlint/oxlint.seatbelt.tsv'; +const DEFAULT_FILE_HEADER = `# ${SEATBELT_NAME} temporarily allowed errors +# docs: config/oxlint/seatbelt.mjs`; + +const LINT_SEVERITY = {WARNING: 1, ERROR: 2}; + +const toRelativePath = (baseFile, filename) => (path.isAbsolute(filename) ? path.relative(path.dirname(baseFile), filename) : filename); +const toAbsolutePath = (baseFile, filename) => (path.isAbsolute(filename) ? filename : path.resolve(path.dirname(baseFile), filename)); + +// Each data line holds tab-separated JSON values. Comments before the first row are the header, comments after the +// last row are trailing, and any other comment travels with the row below it when rows are sorted. +function parseTSV(text) { + const rows = []; + const leading = []; + const pending = []; + let seenData = false; + for (const [index, rawLine] of text.split(/\r?\n/).entries()) { + if (!/\S/.test(rawLine)) { + continue; + } + if (/^\s*#/.test(rawLine)) { + (seenData ? pending : leading).push(rawLine); + continue; + } + const cells = rawLine.split('\t').map((cell, column) => { + try { + return JSON.parse(cell); + } catch { + throw new Error(`Invalid JSON in column ${column + 1} at line ${index + 1}`); + } + }); + rows.push({cells, comments: pending.join('\n')}); + pending.length = 0; + seenData = true; + } + return {leadingComments: leading.join('\n'), trailingComments: pending.join('\n'), rows}; +} + +function serializeTSV({leadingComments, trailingComments, rows}) { + const appendBlock = (output, block) => (block ? output + (block.endsWith('\n') ? block : `${block}\n`) : output); + const items = rows.map((row) => ({comments: row.comments, encoded: `${row.cells.map((cell) => JSON.stringify(cell)).join('\t')}\n`})); + items.sort((left, right) => left.encoded.localeCompare(right.encoded)); + let output = leadingComments ? `${appendBlock('', leadingComments)}\n` : ''; + for (const item of items) { + output = appendBlock(output, item.comments) + item.encoded; + } + return appendBlock(output, trailingComments); +} + +const ruleSetHas = (ruleSet, ruleID) => ruleSet === 'all' || ruleSet.has(ruleID); + +function asSeatbeltLine(row, lineNumber) { + if (row.length !== 3) { + throw new Error(`Expected 3 tab-separated JSON values at line ${lineNumber}, instead have ${row.length}`); + } + const [filename, ruleID, maxErrors] = row; + if (typeof filename !== 'string' || typeof ruleID !== 'string' || typeof maxErrors !== 'number') { + throw new Error(`Expected "file""rule"count at line ${lineNumber}`); + } + return {filename, ruleID, maxErrors, comments: ''}; +} + +function parseSeatbeltTSV(text) { + const parsed = parseTSV(text); + const data = new Map(); + for (const [index, row] of parsed.rows.entries()) { + const line = asSeatbeltLine(row.cells, index + 1); + line.comments = row.comments; + const fileState = data.get(line.filename) ?? {maxErrors: undefined, lines: []}; + fileState.lines.push(line); + data.set(line.filename, fileState); + } + return {data, comments: parsed.leadingComments, trailingComments: parsed.trailingComments}; +} + +function serializeSeatbeltTSV(data, comments, trailingComments = '') { + const rows = []; + for (const [filename, fileState] of data) { + if (fileState.maxErrors) { + const commentsByRule = new Map(fileState.lines.map((line) => [line.ruleID, line.comments])); + fileState.lines = [...fileState.maxErrors].map(([ruleID, maxErrors]) => ({filename, ruleID, maxErrors, comments: commentsByRule.get(ruleID) ?? ''})); + } + for (const line of fileState.lines) { + rows.push({cells: [line.filename, line.ruleID, line.maxErrors], comments: line.comments}); + } + } + return serializeTSV({leadingComments: comments, trailingComments, rows}); +} + +function getMaxErrors(data, relativeFilename) { + const fileState = data.get(relativeFilename); + if (!fileState) { + return undefined; + } + fileState.maxErrors ??= new Map(fileState.lines.map((line) => [line.ruleID, line.maxErrors])); + return fileState.maxErrors; +} + +const isCountableError = (message) => message.severity === LINT_SEVERITY.ERROR && !!message.ruleID; + +function countRuleIDs(messages) { + const counts = new Map(); + for (const message of messages) { + if (isCountableError(message)) { + counts.set(message.ruleID, (counts.get(message.ruleID) ?? 0) + 1); + } + } + return counts; +} + +const pluralErrors = (count) => (count === 1 ? 'error' : 'errors'); + +function compareMessages(a, b) { + if (a.filePath !== b.filePath) { + return a.filePath < b.filePath ? -1 : 1; + } + if (a.ruleID !== b.ruleID) { + if (a.ruleID === null) { + return -1; + } + if (b.ruleID === null) { + return 1; + } + return a.ruleID < b.ruleID ? -1 : 1; + } + return a.line - b.line || a.column - b.column; +} + +// Sorted by file, rule, line and column, so "the first N of M errors" are the same ones on every run. +const canonicalizeMessages = (messages) => [...messages].sort(compareMessages); + +function verboseLog(options, makeMessage) { + if (options.verbose) { + console.error(`[${SEATBELT_NAME}]:`, makeMessage()); + } +} + +const withNote = (message, note, severity = message.severity) => ({...message, severity, message: `${message.message}\n[${SEATBELT_NAME}]: ${note}`}); + +function frozenText(seatbeltFilename, errorCount, maxErrorCount) { + const fixed = maxErrorCount - errorCount; + return `[${SEATBELT_NAME}]: SEATBELT_FROZEN: Expected ${maxErrorCount} ${pluralErrors(maxErrorCount)}, found ${errorCount}. +If you fixed ${fixed} ${fixed === 1 ? 'error' : 'errors'}, thank you, but you'll need to update the seatbelt file to match. +Try running npm run lint, then committing ${seatbeltFilename}.`; +} + +function transformMessages(options, data, filename, messages) { + const ruleToMaxErrorCount = getMaxErrors(data, toRelativePath(options.seatbeltFile, filename)); + const allowIncrease = options.allowIncreaseRules === 'all' || options.allowIncreaseRules.size > 0; + if (!ruleToMaxErrorCount && !allowIncrease) { + return messages; + } + + const ruleToErrorCount = countRuleIDs(messages); + const demoteRemaining = new Map(); + const seenVerbose = new Set(); + const logOnce = (ruleID, text) => { + if (options.verbose && !seenVerbose.has(ruleID)) { + seenVerbose.add(ruleID); + verboseLog(options, () => `${filename}: rule ${ruleID}: ${text}`); + } + }; + + return messages.flatMap((message) => { + if (!isCountableError(message)) { + return message; + } + const errorCount = ruleToErrorCount.get(message.ruleID); + const maxErrorCount = ruleToMaxErrorCount?.get(message.ruleID) ?? 0; + const allowThisIncrease = ruleSetHas(options.allowIncreaseRules, message.ruleID); + if (maxErrorCount === 0 && !allowThisIncrease) { + return message; + } + + if (errorCount > maxErrorCount) { + if (allowThisIncrease) { + const increase = errorCount - maxErrorCount; + return options.quiet ? [] : withNote(message, `SEATBELT_INCREASE: Temporarily allowing ${increase} new ${pluralErrors(increase)} of this type.`, LINT_SEVERITY.WARNING); + } + logOnce(message.ruleID, `error: ${errorCount} ${pluralErrors(errorCount)} found > max ${maxErrorCount}`); + const remaining = demoteRemaining.get(message.ruleID) ?? maxErrorCount; + if (remaining > 0) { + demoteRemaining.set(message.ruleID, remaining - 1); + return options.quiet + ? [] + : withNote( + message, + `This file is temporarily allowed to have ${maxErrorCount} ${pluralErrors(maxErrorCount)} of this type.\nPlease tend the garden by fixing if you have the time.`, + LINT_SEVERITY.WARNING, + ); + } + return withNote( + message, + `There are ${errorCount} ${pluralErrors(errorCount)} of this type, but only ${maxErrorCount} are allowed.\nRemove ${ + errorCount - maxErrorCount + } to turn these errors into warnings.`, + ); + } + + if (errorCount === maxErrorCount) { + logOnce(message.ruleID, `ok: ${errorCount} ${pluralErrors(errorCount)} found == max ${maxErrorCount}`); + return options.quiet + ? [] + : withNote( + message, + `This file is temporarily allowed to have ${errorCount} ${pluralErrors(errorCount)} of this type.\nPlease tend the garden by fixing if you have the time.`, + LINT_SEVERITY.WARNING, + ); + } + + if (options.frozen) { + // Frozen fails on any difference, so a decrease that has not reached zero still blocks the run. + return {...message, severity: LINT_SEVERITY.ERROR, message: `${message.message}\n${frozenText(options.seatbeltFile, errorCount, maxErrorCount)}`}; + } + const fixed = maxErrorCount - errorCount; + return options.quiet + ? [] + : withNote( + message, + `This file is temporarily allowed to have ${maxErrorCount} ${pluralErrors(maxErrorCount)} of this type.\nThank you for fixing ${ + fixed === 1 ? 'one' : `${fixed} errors` + }, it really helps.`, + LINT_SEVERITY.WARNING, + ); + }); +} + +function updateMaxErrors(options, data, filename, ruleToErrorCount) { + const removedRules = new Set(); + let changedRules = 0; + const relativeFilename = toRelativePath(options.seatbeltFile, filename); + const maxErrors = new Map(getMaxErrors(data, relativeFilename) ?? []); + + for (const [ruleID, errorCount] of ruleToErrorCount) { + const maxErrorCount = maxErrors.get(ruleID) ?? 0; + if (errorCount !== maxErrorCount && (errorCount < maxErrorCount || ruleSetHas(options.allowIncreaseRules, ruleID))) { + verboseLog(options, () => `${filename}: rule ${ruleID}: ${options.frozen ? "SEATBELT_FROZEN: didn't update" : 'update'} max errors ${maxErrorCount} -> ${errorCount}`); + maxErrors.set(ruleID, errorCount); + changedRules++; + } + } + + for (const [ruleID, maxErrorCount] of [...maxErrors]) { + if (maxErrorCount !== 0 && ruleToErrorCount.has(ruleID)) { + continue; + } + if (ruleSetHas(options.keepRules, ruleID)) { + verboseLog(options, () => `${filename}: rule ${ruleID}: SEATBELT_KEEP: didn't update max errors ${maxErrorCount} -> 0`); + continue; + } + verboseLog(options, () => `${filename}: rule ${ruleID}: ${options.frozen ? "SEATBELT_FROZEN: didn't update" : 'update'} max errors ${maxErrorCount} -> 0`); + maxErrors.delete(ruleID); + removedRules.add(ruleID); + } + + const changed = (changedRules > 0 || removedRules.size > 0) && !options.frozen; + if (changed) { + const fileState = data.get(relativeFilename); + if (fileState) { + fileState.maxErrors = maxErrors; + } else { + data.set(relativeFilename, {maxErrors, lines: []}); + } + } + return {removedRules, changed}; +} + +function frozenRemovedRuleMessages(filename, seatbeltFile, removedRules, maxErrorsBefore) { + return [...removedRules].map((ruleID) => ({ + filePath: filename, + ruleID, + line: 1, + column: 0, + severity: LINT_SEVERITY.ERROR, + message: frozenText(seatbeltFile, 0, maxErrorsBefore.get(ruleID)), + })); +} + +function writeTSVAtomically(seatbeltFile, tsv) { + const tempPath = `${seatbeltFile}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, tsv); + try { + fs.renameSync(tempPath, seatbeltFile); + } catch (error) { + fs.rmSync(tempPath, {force: true}); + if (error?.code !== 'EXDEV') { + throw error; + } + fs.writeFileSync(seatbeltFile, tsv); + } +} + +const readText = (file) => { + try { + return fs.readFileSync(file, 'utf8'); + } catch { + return undefined; + } +}; + +/** + * @param {Array<{filePath: string, ruleID: string | null, severity: 1 | 2, message: string, line: number, column: number}>} messages + * @param {ReturnType} options + * @param {Iterable} lintedFilenames absolute paths of every linted file, including clean ones + */ +function applySeatbelt(messages, options, lintedFilenames) { + if (options.disable) { + const existing = readText(options.seatbeltFile) ?? DEFAULT_FILE_HEADER; + return {messages, tsv: existing.endsWith('\n') || !existing ? existing : `${existing}\n`, wrote: false, changed: false}; + } + + const existingText = readText(options.seatbeltFile); + const {data, comments, trailingComments} = existingText ? parseSeatbeltTSV(existingText) : {data: new Map(), comments: DEFAULT_FILE_HEADER, trailingComments: ''}; + + const byFile = new Map(); + for (const message of canonicalizeMessages(messages)) { + const fileMessages = byFile.get(message.filePath) ?? []; + fileMessages.push(message); + byFile.set(message.filePath, fileMessages); + } + for (const filename of lintedFilenames) { + if (!byFile.has(filename)) { + byFile.set(filename, []); + } + } + + const transformed = []; + let anyChanged = false; + for (const [filename, fileMessages] of byFile) { + const maxErrorsBefore = new Map(getMaxErrors(data, toRelativePath(options.seatbeltFile, filename)) ?? []); + const after = transformMessages(options, data, filename, fileMessages); + const {removedRules, changed} = updateMaxErrors(options, data, filename, countRuleIDs(fileMessages)); + anyChanged ||= changed; + transformed.push(...after); + if (options.frozen) { + transformed.push(...frozenRemovedRuleMessages(filename, options.seatbeltFile, removedRules, maxErrorsBefore)); + } + } + + // Rows for files that no longer exist are dropped (justjake/eslint-seatbelt#15). + let pruned = 0; + for (const relativeFilename of [...data.keys()]) { + if (fs.existsSync(toAbsolutePath(options.seatbeltFile, relativeFilename))) { + continue; + } + if (options.frozen) { + verboseLog(options, () => `${relativeFilename}: SEATBELT_FROZEN: didn't remove max errors`); + continue; + } + verboseLog(options, () => `${relativeFilename}: remove max errors`); + data.delete(relativeFilename); + anyChanged = true; + pruned++; + } + + const tsv = serializeSeatbeltTSV(data, comments || DEFAULT_FILE_HEADER, trailingComments); + const shouldWrite = anyChanged && !options.frozen && !options.readOnly; + if (pruned > 0) { + console.error(`${SEATBELT_NAME}: ${shouldWrite ? 'removed' : 'would remove'} ${pruned} baseline row(s) for deleted files`); + } + if (shouldWrite) { + writeTSVAtomically(options.seatbeltFile, tsv); + } + return {messages: canonicalizeMessages(transformed), tsv, wrote: shouldWrite, changed: anyChanged}; +} + +// Same parsing as eslint-seatbelt: unset or empty is unset; "0", "false" and "no" are false; anything else is true. +function readBooleanEnvVar(value) { + if (value === undefined || value === '') { + return undefined; + } + return !['0', 'false', 'no'].includes(value.toLowerCase()); +} + +// Same parsing as eslint-seatbelt: unset is unset, empty is no rules, "all"/"1"/"true" is every rule, otherwise a +// comma- or space-separated list of rule IDs. +function parseRuleSetEnvVar(value) { + if (value === undefined) { + return undefined; + } + if (!value) { + return new Set(); + } + return ['all', '1', 'true'].includes(value.toLowerCase()) ? 'all' : new Set(value.split(/[\s,]+/g).filter(Boolean)); +} + +// Same defaults as App: read-only outside CI unless an increase is allowed, never frozen unless asked. +function resolveSeatbeltOptions(projectRoot, env = process.env) { + const allowIncreaseRules = parseRuleSetEnvVar(env.SEATBELT_INCREASE) ?? new Set(); + const isIncreaseSet = allowIncreaseRules === 'all' || allowIncreaseRules.size > 0; + return { + seatbeltFile: path.join(projectRoot, SEATBELT_TSV_RELATIVE), + projectRoot, + disable: readBooleanEnvVar(env.SEATBELT_DISABLE) ?? false, + frozen: readBooleanEnvVar(env.SEATBELT_FROZEN) ?? false, + readOnly: isIncreaseSet ? false : readBooleanEnvVar(env.SEATBELT_READ_ONLY) ?? !readBooleanEnvVar(env.CI), + allowIncreaseRules, + keepRules: parseRuleSetEnvVar(env.SEATBELT_KEEP) ?? new Set(), + quiet: readBooleanEnvVar(env.SEATBELT_QUIET) ?? false, + verbose: readBooleanEnvVar(env.SEATBELT_VERBOSE) ?? false, + }; +} + +export { + LINT_SEVERITY, + SEATBELT_TSV_RELATIVE, + applySeatbelt, + canonicalizeMessages, + countRuleIDs, + parseSeatbeltTSV, + resolveSeatbeltOptions, + serializeSeatbeltTSV, + transformMessages, + updateMaxErrors, +}; diff --git a/config/oxlint/seatbelt.test.mjs b/config/oxlint/seatbelt.test.mjs new file mode 100644 index 000000000..deabba980 --- /dev/null +++ b/config/oxlint/seatbelt.test.mjs @@ -0,0 +1,232 @@ +// Ported from Expensify/App tests/tooling/lintSeatbelt.test.ts. Run with `npm run test:lint`. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {afterEach, describe, it} from 'node:test'; + +import {LINT_SEVERITY, applySeatbelt, canonicalizeMessages, parseSeatbeltTSV, resolveSeatbeltOptions, serializeSeatbeltTSV, transformMessages} from './seatbelt.mjs'; + +function makeOptions(overrides = {}) { + return { + seatbeltFile: '/tmp/config/oxlint/oxlint.seatbelt.tsv', + projectRoot: '/tmp', + disable: false, + frozen: false, + readOnly: true, + allowIncreaseRules: new Set(), + keepRules: new Set(), + quiet: false, + verbose: false, + ...overrides, + }; +} + +function makeMessage(ruleID, overrides = {}) { + return {filePath: '/tmp/src/file.ts', ruleID, severity: LINT_SEVERITY.ERROR, message: `Original: ${ruleID}`, line: 1, column: 1, ...overrides}; +} + +describe('canonicalizeMessages', () => { + it('sorts by filename, ruleID, line, then column', () => { + const messages = [ + makeMessage('b', {filePath: '/z.ts', line: 2, column: 1}), + makeMessage('a', {filePath: '/a.ts', line: 9, column: 1}), + makeMessage('a', {filePath: '/a.ts', line: 1, column: 9}), + makeMessage('a', {filePath: '/a.ts', line: 1, column: 1}), + ]; + assert.deepEqual( + canonicalizeMessages(messages).map((message) => `${message.filePath}:${message.ruleID}:${message.line}:${message.column}`), + ['/a.ts:a:1:1', '/a.ts:a:1:9', '/a.ts:a:9:1', '/z.ts:b:2:1'], + ); + }); +}); + +describe('transformMessages', () => { + it('demotes errors at the baseline to warnings', () => { + const {data} = parseSeatbeltTSV(`"../../src/file.ts"\t"no-console"\t2\n`); + const result = transformMessages(makeOptions(), data, '/tmp/src/file.ts', [makeMessage('no-console'), makeMessage('no-console', {line: 2})]); + assert.equal(result.length, 2); + assert.equal(result[0].severity, LINT_SEVERITY.WARNING); + assert.match(result[0].message, /tend the garden/); + }); + + it('keeps overflow errors as errors', () => { + const {data} = parseSeatbeltTSV(`"../../src/file.ts"\t"no-console"\t1\n`); + const result = transformMessages(makeOptions(), data, '/tmp/src/file.ts', [ + makeMessage('no-console', {line: 1}), + makeMessage('no-console', {line: 2}), + makeMessage('no-console', {line: 3}), + ]); + assert.equal(result.filter((message) => message.severity === LINT_SEVERITY.ERROR).length, 2); + assert.equal(result.filter((message) => message.severity === LINT_SEVERITY.WARNING).length, 1); + assert.match(result.at(-1).message, /Remove/); + }); + + it('demotes the first N overflow occurrences, not an arbitrary subset', () => { + const {data} = parseSeatbeltTSV(`"../../src/file.ts"\t"no-console"\t1\n`); + const result = transformMessages(makeOptions(), data, '/tmp/src/file.ts', [makeMessage('no-console', {line: 10}), makeMessage('no-console', {line: 20})]); + assert.equal(result[0].severity, LINT_SEVERITY.WARNING); + assert.equal(result[0].line, 10); + assert.equal(result[1].severity, LINT_SEVERITY.ERROR); + assert.equal(result[1].line, 20); + }); + + it('quiet suppresses at-max warnings but keeps overflow errors', () => { + const {data} = parseSeatbeltTSV(`"../../src/file.ts"\t"no-console"\t1\n`); + const result = transformMessages(makeOptions({quiet: true}), data, '/tmp/src/file.ts', [makeMessage('no-console', {line: 1}), makeMessage('no-console', {line: 2})]); + assert.equal(result.length, 1); + assert.equal(result[0].severity, LINT_SEVERITY.ERROR); + }); + + it('frozen keeps a partial decrease as an error rather than writing', () => { + const {data} = parseSeatbeltTSV(`"../../src/file.ts"\t"no-console"\t5\n`); + const result = transformMessages(makeOptions({frozen: true}), data, '/tmp/src/file.ts', [makeMessage('no-console'), makeMessage('no-console', {line: 2})]); + assert.equal(result[0].severity, LINT_SEVERITY.ERROR); + assert.match(result[0].message, /SEATBELT_FROZEN/); + assert.match(result[0].message, /oxlint\.seatbelt\.tsv/); + assert.doesNotMatch(result[0].message, /\/tmp\/src\/file\.ts/); + }); + + it('leaves unbaselined rules untouched', () => { + const {data} = parseSeatbeltTSV(`"../../src/file.ts"\t"no-console"\t1\n`); + assert.deepEqual(transformMessages(makeOptions(), data, '/tmp/src/file.ts', [makeMessage('no-debugger')]), [makeMessage('no-debugger')]); + }); +}); + +describe('serializeSeatbeltTSV', () => { + const rewriteThroughMaxErrors = (data) => { + for (const fileState of data.values()) { + fileState.maxErrors = new Map(fileState.lines.map((line) => [line.ruleID, line.maxErrors])); + } + }; + + it('round-trips the committed header and row format', () => { + const original = `# seatbelt temporarily allowed errors +# docs: config/oxlint/seatbelt.mjs + +"../../src/a.ts" "no-console" 1 +"../../src/b.ts" "typescript/no-deprecated" 2 +`; + const {data, comments} = parseSeatbeltTSV(original); + rewriteThroughMaxErrors(data); + assert.equal(serializeSeatbeltTSV(data, comments), original); + }); + + it('keeps per-row and trailing comments when rewriting counts', () => { + const original = `# seatbelt temporarily allowed errors + +"../../src/a.ts" "no-console" 1 +# tracked in Expensify/react-native-onyx#123 +"../../src/b.ts" "no-debugger" 2 +# leftover +`; + const {data, comments, trailingComments} = parseSeatbeltTSV(original); + rewriteThroughMaxErrors(data); + assert.equal(serializeSeatbeltTSV(data, comments, trailingComments), original); + }); +}); + +describe('applySeatbelt', () => { + const dirs = []; + afterEach(() => { + dirs.splice(0).forEach((dir) => fs.rmSync(dir, {recursive: true, force: true})); + }); + + function setup(tsv, files = ['src.ts']) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-seatbelt-')); + dirs.push(dir); + files.forEach((file) => fs.writeFileSync(path.join(dir, file), 'x\n')); + const tsvPath = path.join(dir, 'oxlint.seatbelt.tsv'); + fs.writeFileSync(tsvPath, tsv); + return {dir, tsvPath, file: (name) => path.join(dir, name)}; + } + + it('does not write in readOnly mode when counts go down', () => { + const tsv = `"src.ts"\t"no-console"\t2\n`; + const {dir, tsvPath, file} = setup(tsv); + const result = applySeatbelt([makeMessage('no-console', {filePath: file('src.ts')})], makeOptions({seatbeltFile: tsvPath, projectRoot: dir, readOnly: true}), [file('src.ts')]); + assert.equal(result.wrote, false); + assert.equal(result.changed, true); + assert.equal(fs.readFileSync(tsvPath, 'utf8'), tsv); + }); + + it('writes a tightened TSV when readOnly is off', () => { + const {dir, tsvPath, file} = setup(`"src.ts"\t"no-console"\t2\n`); + const result = applySeatbelt([makeMessage('no-console', {filePath: file('src.ts')})], makeOptions({seatbeltFile: tsvPath, projectRoot: dir, readOnly: false}), [file('src.ts')]); + assert.equal(result.wrote, true); + assert.match(result.tsv, /"src\.ts"\t"no-console"\t1/); + assert.equal(fs.readFileSync(tsvPath, 'utf8'), result.tsv); + }); + + it('fails an increase without SEATBELT_INCREASE and does not write', () => { + const original = `"src.ts"\t"no-console"\t1\n`; + const {dir, tsvPath, file} = setup(original); + const result = applySeatbelt( + [makeMessage('no-console', {filePath: file('src.ts'), line: 1}), makeMessage('no-console', {filePath: file('src.ts'), line: 2})], + makeOptions({seatbeltFile: tsvPath, projectRoot: dir, readOnly: false}), + [file('src.ts')], + ); + assert.equal(result.wrote, false); + assert.ok(result.messages.some((message) => message.severity === LINT_SEVERITY.ERROR && message.message.includes('Remove'))); + assert.equal(fs.readFileSync(tsvPath, 'utf8'), original); + }); + + it('allows an increase when SEATBELT_INCREASE names the rule', () => { + const {dir, tsvPath, file} = setup(`"src.ts"\t"no-console"\t1\n`); + const result = applySeatbelt( + [makeMessage('no-console', {filePath: file('src.ts'), line: 1}), makeMessage('no-console', {filePath: file('src.ts'), line: 2})], + makeOptions({seatbeltFile: tsvPath, projectRoot: dir, readOnly: false, allowIncreaseRules: new Set(['no-console'])}), + [file('src.ts')], + ); + assert.equal(result.wrote, true); + assert.match(result.tsv, /"src\.ts"\t"no-console"\t2/); + assert.ok(result.messages.every((message) => message.severity === LINT_SEVERITY.WARNING)); + }); + + it('prunes rows for deleted files on the write path', () => { + const {dir, tsvPath, file} = setup(`"gone.ts"\t"no-console"\t1\n"kept.ts"\t"no-console"\t1\n`, ['kept.ts']); + const result = applySeatbelt([makeMessage('no-console', {filePath: file('kept.ts')})], makeOptions({seatbeltFile: tsvPath, projectRoot: dir, readOnly: false}), [file('kept.ts')]); + assert.equal(result.wrote, true); + assert.doesNotMatch(result.tsv, /gone\.ts/); + assert.match(result.tsv, /kept\.ts/); + }); + + it('does not prune deleted-file rows when frozen', () => { + const original = `"gone.ts"\t"no-console"\t1\n"kept.ts"\t"no-console"\t1\n`; + const {dir, tsvPath, file} = setup(original, ['kept.ts']); + const result = applySeatbelt([makeMessage('no-console', {filePath: file('kept.ts')})], makeOptions({seatbeltFile: tsvPath, projectRoot: dir, frozen: true, readOnly: false}), [ + file('kept.ts'), + ]); + assert.equal(result.wrote, false); + assert.equal(fs.readFileSync(tsvPath, 'utf8'), original); + }); + + it('names the TSV path when a frozen run drops a rule entirely', () => { + const {dir, tsvPath, file} = setup(`"kept.ts"\t"no-console"\t1\n`, ['kept.ts']); + const result = applySeatbelt([], makeOptions({seatbeltFile: tsvPath, projectRoot: dir, frozen: true, readOnly: false}), [file('kept.ts')]); + assert.ok(result.messages.some((message) => message.message.includes(tsvPath) && message.message.includes('SEATBELT_FROZEN'))); + assert.ok(!result.messages.some((message) => message.message.includes('kept.ts') && !message.message.includes(tsvPath))); + }); + + it('is a no-op when SEATBELT_DISABLE is set', () => { + const {tsvPath} = setup(`"src.ts"\t"no-console"\t1\n`); + const incoming = [makeMessage('no-console')]; + const result = applySeatbelt(incoming, makeOptions({seatbeltFile: tsvPath, disable: true}), ['/tmp/src/file.ts']); + assert.equal(result.messages, incoming); + assert.equal(result.wrote, false); + }); +}); + +describe('resolveSeatbeltOptions', () => { + it('reads only outside CI, writes in CI, and is never frozen by default', () => { + assert.equal(resolveSeatbeltOptions('/repo', {}).readOnly, true); + assert.equal(resolveSeatbeltOptions('/repo', {CI: 'true'}).readOnly, false); + assert.equal(resolveSeatbeltOptions('/repo', {CI: 'true'}).frozen, false); + }); + + it('lets SEATBELT_FROZEN freeze and SEATBELT_INCREASE force writes', () => { + assert.equal(resolveSeatbeltOptions('/repo', {SEATBELT_FROZEN: '1'}).frozen, true); + assert.equal(resolveSeatbeltOptions('/repo', {SEATBELT_READ_ONLY: '1', SEATBELT_INCREASE: 'no-console'}).readOnly, false); + assert.equal(resolveSeatbeltOptions('/repo', {SEATBELT_INCREASE: 'all'}).allowIncreaseRules, 'all'); + }); +}); diff --git a/config/oxlint/sourceConfig.mjs b/config/oxlint/sourceConfig.mjs index adeb50f26..edc547dbc 100644 --- a/config/oxlint/sourceConfig.mjs +++ b/config/oxlint/sourceConfig.mjs @@ -1,5 +1,6 @@ // Oxlint's config is generated from this file (see derive.mjs). It is a copy of eslint.config.mjs without the // rulesdir setup, so Oxlint keeps working after eslint.config.mjs is deleted. Until then, change both files together. +// Unlike ESLint, Oxlint keeps existing violations as errors: the seatbelt (seatbelt.mjs) grandfathers them per file. import browserConfig from 'eslint-config-expensify/browser'; import jestConfig from 'eslint-config-expensify/jest'; import reactConfig from 'eslint-config-expensify/react'; @@ -9,9 +10,7 @@ import prettierConfig from 'eslint-config-prettier'; import {defineConfig, globalIgnores} from 'eslint/config'; import globals from 'globals'; -import downgradeDebtRules from '../lintDebt.mjs'; - -const configs = defineConfig([ +export default defineConfig([ globalIgnores([ 'dist/**', 'node_modules/**', @@ -137,5 +136,3 @@ const configs = defineConfig([ }, prettierConfig, ]); - -export default configs.map((entry) => (entry.rules ? {...entry, rules: downgradeDebtRules(entry.rules)} : entry)); diff --git a/package.json b/package.json index 30b46798d..a8cd9606a 100644 --- a/package.json +++ b/package.json @@ -29,17 +29,18 @@ "types": "dist/index.d.ts", "scripts": { "prepare": "npm run build", - "lint": "oxlint --type-aware --max-warnings 665", + "lint": "node config/oxlint/lint.mjs", "lint:eslint": "eslint . --max-warnings 713", "typecheck": "tsc --noEmit", "test": "jest", + "test:lint": "node --test config/oxlint/", "test:types": "npm run build && tsc --noEmit --project tsconfig.test.json", "perf-test": "npx reassure", "build": "tsc -p tsconfig.build.json", "build:watch": "nodemon --watch lib --ext js,json,ts,tsx --exec \"npm run build && npm pack\"", "prebuild:docs": "npm run build", "build:docs": "ts-node buildDocs.ts", - "lint-tests": "oxlint --type-aware tests", + "lint-tests": "node config/oxlint/lint.mjs tests", "prettier": "prettier --write .", "gh-actions-build": "./.github/scripts/buildActions.sh" }, From 0642b5ecbc39a94e4455ea8cad1af7b6d5955ffe Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 12:39:35 +0100 Subject: [PATCH 14/43] Trim seatbelt comments and run test:lint by file for Node 24 --- .github/workflows/lint.yml | 2 +- config/oxlint/lint.mjs | 3 +-- config/oxlint/seatbelt.mjs | 22 ++++++---------------- config/oxlint/seatbelt.test.mjs | 2 +- package.json | 2 +- 5 files changed, 10 insertions(+), 21 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 142b28701..63248069e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -40,7 +40,7 @@ jobs: CI: true # On main, lint rewrites the seatbelt file when merged PRs fixed grandfathered errors. os-botify commits it - # back, through the same GitHub App token and contents API the publish workflow uses for version bumps. + # back the same way the publish workflow commits version bumps. - name: Check for tightened seatbelt baseline id: seatbelt if: github.event_name == 'push' diff --git a/config/oxlint/lint.mjs b/config/oxlint/lint.mjs index a8163eef3..a4d0e1e8f 100644 --- a/config/oxlint/lint.mjs +++ b/config/oxlint/lint.mjs @@ -1,5 +1,4 @@ -// Runs Oxlint, then the seatbelt ratchet (seatbelt.mjs), then prints the result. Ported from Expensify/App -// scripts/lint/ without App's sharding, React Compiler filter and no-deprecated stratification. +// Runs Oxlint, applies the seatbelt (seatbelt.mjs) and prints the result. // // npm run lint lint the whole repo // npm run lint -- lib/Onyx.ts tests lint the given paths diff --git a/config/oxlint/seatbelt.mjs b/config/oxlint/seatbelt.mjs index dcee66221..4de6ed5e5 100644 --- a/config/oxlint/seatbelt.mjs +++ b/config/oxlint/seatbelt.mjs @@ -1,10 +1,8 @@ -// Seatbelt ratchet for Oxlint, ported from Expensify/App scripts/lint/processors/Seatbelt.ts (which follows -// justjake/eslint-seatbelt). config/oxlint/oxlint.seatbelt.tsv records, per file and rule, how many errors are -// allowed. Errors up to that count report as warnings; one more fails the run. Counts can only go down, unless -// SEATBELT_INCREASE names the rule. +// config/oxlint/oxlint.seatbelt.tsv records, per file and rule, how many errors are allowed. Errors up to that count +// report as warnings; one more fails the run. Counts can only go down, unless SEATBELT_INCREASE names the rule. // -// As in App, local runs only read the file, so fixing errors leaves the working tree clean. CI writes it, and the -// lint job on main commits the tightened file back as os-botify (.github/workflows/lint.yml). +// Local runs only read the file, so fixing errors leaves the working tree clean. CI writes it, and the lint job on +// main commits the tightened file back as os-botify (.github/workflows/lint.yml). // // Env: SEATBELT_INCREASE= allows and records an increase (commit the file with your change), // SEATBELT_READ_ONLY=0 writes locally, SEATBELT_FROZEN=1 fails on any difference from the file, SEATBELT_KEEP= @@ -314,11 +312,7 @@ const readText = (file) => { } }; -/** - * @param {Array<{filePath: string, ruleID: string | null, severity: 1 | 2, message: string, line: number, column: number}>} messages - * @param {ReturnType} options - * @param {Iterable} lintedFilenames absolute paths of every linted file, including clean ones - */ +// lintedFilenames must include files without findings, so rows for fully fixed files are removed. function applySeatbelt(messages, options, lintedFilenames) { if (options.disable) { const existing = readText(options.seatbeltFile) ?? DEFAULT_FILE_HEADER; @@ -353,7 +347,7 @@ function applySeatbelt(messages, options, lintedFilenames) { } } - // Rows for files that no longer exist are dropped (justjake/eslint-seatbelt#15). + // Rows for files that no longer exist are dropped. let pruned = 0; for (const relativeFilename of [...data.keys()]) { if (fs.existsSync(toAbsolutePath(options.seatbeltFile, relativeFilename))) { @@ -380,7 +374,6 @@ function applySeatbelt(messages, options, lintedFilenames) { return {messages: canonicalizeMessages(transformed), tsv, wrote: shouldWrite, changed: anyChanged}; } -// Same parsing as eslint-seatbelt: unset or empty is unset; "0", "false" and "no" are false; anything else is true. function readBooleanEnvVar(value) { if (value === undefined || value === '') { return undefined; @@ -388,8 +381,6 @@ function readBooleanEnvVar(value) { return !['0', 'false', 'no'].includes(value.toLowerCase()); } -// Same parsing as eslint-seatbelt: unset is unset, empty is no rules, "all"/"1"/"true" is every rule, otherwise a -// comma- or space-separated list of rule IDs. function parseRuleSetEnvVar(value) { if (value === undefined) { return undefined; @@ -400,7 +391,6 @@ function parseRuleSetEnvVar(value) { return ['all', '1', 'true'].includes(value.toLowerCase()) ? 'all' : new Set(value.split(/[\s,]+/g).filter(Boolean)); } -// Same defaults as App: read-only outside CI unless an increase is allowed, never frozen unless asked. function resolveSeatbeltOptions(projectRoot, env = process.env) { const allowIncreaseRules = parseRuleSetEnvVar(env.SEATBELT_INCREASE) ?? new Set(); const isIncreaseSet = allowIncreaseRules === 'all' || allowIncreaseRules.size > 0; diff --git a/config/oxlint/seatbelt.test.mjs b/config/oxlint/seatbelt.test.mjs index deabba980..d0bfd1d2d 100644 --- a/config/oxlint/seatbelt.test.mjs +++ b/config/oxlint/seatbelt.test.mjs @@ -1,4 +1,4 @@ -// Ported from Expensify/App tests/tooling/lintSeatbelt.test.ts. Run with `npm run test:lint`. +// Runs on Node's test runner, not Jest: `npm run test:lint`. import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; diff --git a/package.json b/package.json index a8cd9606a..b5b853c03 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "lint:eslint": "eslint . --max-warnings 713", "typecheck": "tsc --noEmit", "test": "jest", - "test:lint": "node --test config/oxlint/", + "test:lint": "node --test config/oxlint/seatbelt.test.mjs", "test:types": "npm run build && tsc --noEmit --project tsconfig.test.json", "perf-test": "npx reassure", "build": "tsc -p tsconfig.build.json", From 62e4a6ca802c1167f99af4ce2f00852839e37178 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 13:04:14 +0100 Subject: [PATCH 15/43] Migrate only eslint-config-expensify presets and keep Onyx overrides in Oxlint format --- config/lintDebt.mjs | 50 ----------- config/oxlint/derive.mjs | 65 +++++++++++--- config/oxlint/presets.mjs | 14 +++ config/oxlint/sourceConfig.mjs | 138 ------------------------------ eslint.config.mjs | 152 --------------------------------- oxlint.config.mts | 122 +++++++++++++++++++++++++- package.json | 1 - 7 files changed, 188 insertions(+), 354 deletions(-) delete mode 100644 config/lintDebt.mjs create mode 100644 config/oxlint/presets.mjs delete mode 100644 config/oxlint/sourceConfig.mjs delete mode 100644 eslint.config.mjs diff --git a/config/lintDebt.mjs b/config/lintDebt.mjs deleted file mode 100644 index 2056c2566..000000000 --- a/config/lintDebt.mjs +++ /dev/null @@ -1,50 +0,0 @@ -// ESLint only: rules that already had violations when eslint-config-expensify 4.x was adopted report as warnings -// instead of errors, and `npm run lint:eslint` fails above the current warning count. Oxlint uses the seatbelt -// (config/oxlint/seatbelt.mjs) instead. Remove a rule here, and lower the count, once it reaches zero. -const LINT_DEBT_RULES = new Set([ - '@typescript-eslint/consistent-type-definitions', - '@typescript-eslint/naming-convention', - '@typescript-eslint/no-deprecated', - '@typescript-eslint/no-empty-object-type', - '@typescript-eslint/no-loop-func', - '@typescript-eslint/no-misused-promises', - '@typescript-eslint/no-non-null-assertion', - '@typescript-eslint/no-redundant-type-constituents', - '@typescript-eslint/no-restricted-types', - '@typescript-eslint/no-unnecessary-type-assertion', - '@typescript-eslint/no-unsafe-argument', - '@typescript-eslint/no-unsafe-assignment', - '@typescript-eslint/no-unsafe-call', - '@typescript-eslint/no-unsafe-member-access', - '@typescript-eslint/no-unsafe-return', - '@typescript-eslint/no-unsafe-type-assertion', - '@typescript-eslint/no-unused-vars', - '@typescript-eslint/no-use-before-define', - '@typescript-eslint/non-nullable-type-assertion-style', - '@typescript-eslint/prefer-nullish-coalescing', - '@typescript-eslint/restrict-template-expressions', - '@typescript-eslint/unbound-method', - 'import/no-extraneous-dependencies', - 'jsdoc/no-types', - 'rulesdir/prefer-at', -]); - -const isError = (severity) => severity === 'error' || severity === 2; - -function downgradeToWarn(entry) { - if (Array.isArray(entry)) { - return isError(entry[0]) ? ['warn', ...entry.slice(1)] : entry; - } - return isError(entry) ? 'warn' : entry; -} - -/** - * @template {Record} T - * @param {T} rules - * @returns {T} - */ -function downgradeDebtRules(rules) { - return /** @type {T} */ (Object.fromEntries(Object.entries(rules).map(([ruleId, entry]) => [ruleId, LINT_DEBT_RULES.has(ruleId) ? downgradeToWarn(entry) : entry]))); -} - -export default downgradeDebtRules; diff --git a/config/oxlint/derive.mjs b/config/oxlint/derive.mjs index c441dca88..2e37be929 100644 --- a/config/oxlint/derive.mjs +++ b/config/oxlint/derive.mjs @@ -1,15 +1,16 @@ -// Oxlint's config, generated from sourceConfig.mjs each time Oxlint runs, so updating eslint-config-expensify needs no -// manual changes. @oxlint/migrate turns each ESLint rule into Oxlint's built-in version. Rules Oxlint has no built-in +// Oxlint's config, generated from the eslint-config-expensify presets (presets.mjs) each time Oxlint runs, so updating +// eslint-config-expensify needs no manual changes. @oxlint/migrate turns each ESLint rule into Oxlint's built-in version. Rules Oxlint has no built-in // version of run with ESLint's own code through the plugins in ./plugins, with the same settings as in ESLint. // A rule that cannot run either way must be listed in NOT_PORTED or STAND_INS, otherwise Oxlint stops with an error, // so no rule is dropped without a decision. The plugin files read their rule lists from this module. +// oxlint.config.mts adds Onyx's own settings in Oxlint format through extendOxlintConfig. import migrate from '@oxlint/migrate'; import {builtinRules} from 'eslint/use-at-your-own-risk'; import {createRequire} from 'node:module'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import eslintConfig from './sourceConfig.mjs'; +import eslintConfig from './presets.mjs'; const require = createRequire(import.meta.url); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -171,20 +172,21 @@ function fixRules(rules = {}, isTypeScriptBlock = false) { return out; } +const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); +const mergeSettings = (target, source) => { + for (const [key, value] of Object.entries(source)) { + target[key] = isObject(value) && isObject(target[key]) ? mergeSettings({...target[key]}, value) : value; + } + return target; +}; + // ESLint's import rules (run as hosted) read these settings. Oxlint cannot set settings per file pattern, so all // blocks are merged into one. `import/parsers` is skipped because Oxlint parses the files itself. function importSettings() { - const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); - const merge = (target, source) => { - for (const [key, value] of Object.entries(source)) { - target[key] = isObject(value) && isObject(target[key]) ? merge({...target[key]}, value) : value; - } - return target; - }; const settings = {}; for (const block of eslintConfig) { const entries = Object.entries(block.settings ?? {}).filter(([key]) => key.startsWith('import/') && key !== 'import/parsers'); - merge(settings, Object.fromEntries(entries)); + mergeSettings(settings, Object.fromEntries(entries)); } return settings; } @@ -291,4 +293,43 @@ const oxlintConfig = { ], }; -export {STUBBED_PARSER_SERVICES, coreRuleNames, hostedRuleIds, oxlintConfig, pluginRules, splitRuleId}; +const oxlintSchema = require(path.join(path.dirname(require.resolve('oxlint/package.json')), 'configuration_schema.json')); +const builtInRules = new Set(Object.keys(oxlintSchema.definitions.DummyRuleMap.properties)); +const otherJsPlugins = new Set(oxlintConfig.jsPlugins.map((plugin) => plugin.name).filter((name) => name !== 'core' && name !== 'hosted')); + +// A rule name written by hand must match how Oxlint runs that rule. If a preset update moves a rule between Oxlint's +// built-in version and `hosted/` or `core/`, the old name would be ignored silently, so it fails here instead. +function checkRuleName(rule) { + const slash = rule.indexOf('/'); + const plugin = slash === -1 ? '' : rule.slice(0, slash); + const name = rule.slice(slash + 1); + let known; + if (plugin === 'core') { + known = coreRuleNames.includes(name); + } else if (plugin === 'hosted') { + known = hostedNames.includes(name); + } else { + // A built-in name is ignored when ESLint's code runs that rule instead. + known = otherJsPlugins.has(plugin) || (builtInRules.has(rule) && !ALWAYS_HOSTED.has(rule) && !coreRuleNames.includes(rule)); + } + if (!known) { + throw new Error(`oxlint.config.mts sets "${rule}", which is not a rule Oxlint runs here. Check whether it runs as hosted/, core/ or built in.`); + } +} + +/** + * Adds Onyx's own ignores, settings and overrides, written in Oxlint format, to the config generated from the presets. + * @param {Pick} extra + * @returns {import('oxlint').OxlintConfig} + */ +function extendOxlintConfig({ignorePatterns = [], settings = {}, overrides = []}) { + overrides.flatMap((override) => Object.keys(override.rules ?? {})).forEach(checkRuleName); + return { + ...oxlintConfig, + ignorePatterns: [...(oxlintConfig.ignorePatterns ?? []), ...ignorePatterns], + settings: mergeSettings({...oxlintConfig.settings}, settings), + overrides: [...oxlintConfig.overrides, ...overrides], + }; +} + +export {STUBBED_PARSER_SERVICES, coreRuleNames, extendOxlintConfig, hostedRuleIds, oxlintConfig, pluginRules, splitRuleId}; diff --git a/config/oxlint/presets.mjs b/config/oxlint/presets.mjs new file mode 100644 index 000000000..4b086a4d6 --- /dev/null +++ b/config/oxlint/presets.mjs @@ -0,0 +1,14 @@ +// The eslint-config-expensify presets Onyx lints with. They stay in ESLint format because that is what the package +// ships and what @oxlint/migrate converts (derive.mjs). Onyx's own ignores, settings and overrides are in +// oxlint.config.mts, in Oxlint format. +import browserConfig from 'eslint-config-expensify/browser'; +import jestConfig from 'eslint-config-expensify/jest'; +import reactConfig from 'eslint-config-expensify/react'; +import scriptsConfig from 'eslint-config-expensify/scripts'; +import typescriptConfig from 'eslint-config-expensify/typescript'; +import prettierConfig from 'eslint-config-prettier'; + +// The jest preset applies to Onyx's test and mock files only. +const JEST_FILES = ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts']; + +export default [...browserConfig, ...reactConfig, ...typescriptConfig, ...jestConfig.map((block) => ({...block, files: JEST_FILES})), ...scriptsConfig, prettierConfig]; diff --git a/config/oxlint/sourceConfig.mjs b/config/oxlint/sourceConfig.mjs deleted file mode 100644 index edc547dbc..000000000 --- a/config/oxlint/sourceConfig.mjs +++ /dev/null @@ -1,138 +0,0 @@ -// Oxlint's config is generated from this file (see derive.mjs). It is a copy of eslint.config.mjs without the -// rulesdir setup, so Oxlint keeps working after eslint.config.mjs is deleted. Until then, change both files together. -// Unlike ESLint, Oxlint keeps existing violations as errors: the seatbelt (seatbelt.mjs) grandfathers them per file. -import browserConfig from 'eslint-config-expensify/browser'; -import jestConfig from 'eslint-config-expensify/jest'; -import reactConfig from 'eslint-config-expensify/react'; -import scriptsConfig from 'eslint-config-expensify/scripts'; -import tsExpensifyConfig from 'eslint-config-expensify/typescript'; -import prettierConfig from 'eslint-config-prettier'; -import {defineConfig, globalIgnores} from 'eslint/config'; -import globals from 'globals'; - -export default defineConfig([ - globalIgnores([ - 'dist/**', - 'node_modules/**', - '**/*.d.ts', - '**/*.config.js', - '**/*.config.cjs', - 'tests/types/**/*.ts', - 'cpp/**', - 'bench-results/**', - '.github/**', - 'oxlint.config.mts', - 'config/oxlint/**', - ]), - ...browserConfig, - ...reactConfig, - ...tsExpensifyConfig, - ...jestConfig.map((config) => ({ - ...config, - files: ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts'], - })), - ...scriptsConfig, - { - files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], - settings: { - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', - }, - }, - }, - rules: { - 'react/prop-types': 'off', - 'react/require-default-props': 'off', - }, - }, - { - files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], - languageOptions: { - globals: globals.node, - }, - }, - // A later block replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript - // and allows Onyx key strings (underscores) and numeric collection IDs as object keys. - { - files: ['tests/**/*'], - rules: { - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: ['variable', 'property'], - format: null, - filter: { - regex: '^__esModule$', - match: true, - }, - }, - { - selector: ['variable', 'property'], - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - filter: { - regex: '^private_[a-z][a-zA-Z0-9]*$', - match: false, - }, - }, - { - selector: 'objectLiteralProperty', - format: null, - filter: { - regex: '_', - match: true, - }, - }, - { - selector: 'objectLiteralProperty', - format: null, - filter: { - regex: '^[0-9]+$', - match: true, - }, - }, - { - selector: 'function', - format: ['camelCase', 'PascalCase'], - }, - { - selector: ['typeLike', 'enumMember'], - format: ['PascalCase'], - }, - { - selector: ['parameter', 'method'], - format: ['camelCase', 'PascalCase'], - leadingUnderscore: 'allow', - }, - ], - }, - }, - { - files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], - rules: { - 'import/extensions': 'off', - }, - }, - { - files: ['lib/storage/providers/MemoryOnlyProvider.ts'], - languageOptions: { - globals: { - ...globals.jest, - }, - }, - }, - { - files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], - rules: { - '@typescript-eslint/dot-notation': 'off', - }, - }, - { - files: ['tests/perf-test/**/*'], - rules: { - 'rulesdir/prefer-at': 'off', - }, - }, - prettierConfig, -]); diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 0837b470f..000000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,152 +0,0 @@ -import browserConfig from 'eslint-config-expensify/browser'; -import jestConfig from 'eslint-config-expensify/jest'; -import reactConfig from 'eslint-config-expensify/react'; -import scriptsConfig from 'eslint-config-expensify/scripts'; -import tsExpensifyConfig from 'eslint-config-expensify/typescript'; -import prettierConfig from 'eslint-config-prettier'; -import rulesdir from 'eslint-plugin-rulesdir'; -import {defineConfig, globalIgnores} from 'eslint/config'; -import globals from 'globals'; -import {createRequire} from 'node:module'; -import path from 'node:path'; - -// oxlint-disable-next-line import/extensions -- Oxlint's port reports this import; ESLint's skips it because its resolver cannot resolve the path. -import downgradeDebtRules from './config/lintDebt.mjs'; - -const require = createRequire(import.meta.url); -const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify')); -rulesdir.RULES_DIR = path.resolve(expensifyConfigDirectory, 'eslint-plugin-expensify'); - -const configs = defineConfig([ - globalIgnores([ - 'dist/**', - 'node_modules/**', - '**/*.d.ts', - '**/*.config.js', - '**/*.config.cjs', - 'tests/types/**/*.ts', - 'cpp/**', - 'bench-results/**', - '.github/**', - 'oxlint.config.mts', - 'config/oxlint/**', - ]), - ...browserConfig, - ...reactConfig, - ...tsExpensifyConfig, - ...jestConfig.map((config) => ({ - ...config, - files: ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts'], - })), - ...scriptsConfig, - { - plugins: { - rulesdir, - }, - }, - { - files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], - settings: { - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', - }, - }, - }, - rules: { - 'react/prop-types': 'off', - 'react/require-default-props': 'off', - }, - }, - { - files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], - languageOptions: { - globals: globals.node, - }, - }, - // A later block replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript - // and allows Onyx key strings (underscores) and numeric collection IDs as object keys. - { - files: ['tests/**/*'], - rules: { - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: ['variable', 'property'], - format: null, - filter: { - regex: '^__esModule$', - match: true, - }, - }, - { - selector: ['variable', 'property'], - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - filter: { - regex: '^private_[a-z][a-zA-Z0-9]*$', - match: false, - }, - }, - { - selector: 'objectLiteralProperty', - format: null, - filter: { - regex: '_', - match: true, - }, - }, - { - selector: 'objectLiteralProperty', - format: null, - filter: { - regex: '^[0-9]+$', - match: true, - }, - }, - { - selector: 'function', - format: ['camelCase', 'PascalCase'], - }, - { - selector: ['typeLike', 'enumMember'], - format: ['PascalCase'], - }, - { - selector: ['parameter', 'method'], - format: ['camelCase', 'PascalCase'], - leadingUnderscore: 'allow', - }, - ], - }, - }, - { - files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], - rules: { - 'import/extensions': 'off', - }, - }, - { - files: ['lib/storage/providers/MemoryOnlyProvider.ts'], - languageOptions: { - globals: { - ...globals.jest, - }, - }, - }, - { - files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], - rules: { - '@typescript-eslint/dot-notation': 'off', - }, - }, - { - files: ['tests/perf-test/**/*'], - rules: { - 'rulesdir/prefer-at': 'off', - }, - }, - prettierConfig, -]); - -export default configs.map((entry) => (entry.rules ? {...entry, rules: downgradeDebtRules(entry.rules)} : entry)); diff --git a/oxlint.config.mts b/oxlint.config.mts index 5921bd42b..bab40b8f9 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -1 +1,121 @@ -export {oxlintConfig as default} from './config/oxlint/derive.mjs'; +// Rules come from the eslint-config-expensify presets (config/oxlint/presets.mjs), converted on every run by +// config/oxlint/derive.mjs. This file only adds Onyx's own ignores, settings and overrides. +import {extendOxlintConfig} from './config/oxlint/derive.mjs'; + +export default extendOxlintConfig({ + ignorePatterns: [ + 'dist/**', + 'node_modules/**', + '**/*.d.ts', + '**/*.config.js', + '**/*.config.cjs', + 'tests/types/**/*.ts', + 'cpp/**', + 'bench-results/**', + '.github/**', + 'oxlint.config.mts', + 'config/oxlint/**', + ], + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', + }, + }, + }, + overrides: [ + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + 'hosted/prop-types': 'off', + 'hosted/require-default-props': 'off', + }, + }, + { + files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], + env: { + node: true, + }, + }, + // Overriding replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript + // and allows Onyx key strings (underscores) and numeric collection IDs as object keys. + { + files: ['tests/**/*'], + rules: { + 'hosted/naming-convention': [ + 'error', + { + selector: ['variable', 'property'], + format: null, + filter: { + regex: '^__esModule$', + match: true, + }, + }, + { + selector: ['variable', 'property'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + filter: { + regex: '^private_[a-z][a-zA-Z0-9]*$', + match: false, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '_', + match: true, + }, + }, + { + selector: 'objectLiteralProperty', + format: null, + filter: { + regex: '^[0-9]+$', + match: true, + }, + }, + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + { + selector: ['typeLike', 'enumMember'], + format: ['PascalCase'], + }, + { + selector: ['parameter', 'method'], + format: ['camelCase', 'PascalCase'], + leadingUnderscore: 'allow', + }, + ], + }, + }, + { + files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], + rules: { + 'import/extensions': 'off', + }, + }, + { + files: ['lib/storage/providers/MemoryOnlyProvider.ts'], + env: { + jest: true, + }, + }, + { + files: ['tests/**/*Test.ts', 'tests/perf-test/**/*'], + rules: { + 'typescript/dot-notation': 'off', + }, + }, + { + files: ['tests/perf-test/**/*'], + rules: { + 'unicorn/prefer-at': 'off', + }, + }, + ], +}); diff --git a/package.json b/package.json index b5b853c03..4a6b1283a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "scripts": { "prepare": "npm run build", "lint": "node config/oxlint/lint.mjs", - "lint:eslint": "eslint . --max-warnings 713", "typecheck": "tsc --noEmit", "test": "jest", "test:lint": "node --test config/oxlint/seatbelt.test.mjs", From f382c6b487e90f063050c7a684ed9c46b73ee0e1 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 13:20:06 +0100 Subject: [PATCH 16/43] Keep CommonJS tsconfig for GitHub Action builds --- .github/actions/javascript/tsconfig.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .github/actions/javascript/tsconfig.json diff --git a/.github/actions/javascript/tsconfig.json b/.github/actions/javascript/tsconfig.json new file mode 100644 index 000000000..25254e257 --- /dev/null +++ b/.github/actions/javascript/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node" + } +} From eee5353330f6ae334e1a6c604681864f9f469371 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 13:21:01 +0100 Subject: [PATCH 17/43] Merge dual disable comments into oxlint-disable now that ESLint is gone --- lib/OnyxCache.ts | 6 +++--- lib/utils.ts | 6 +++--- tests/types.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/OnyxCache.ts b/lib/OnyxCache.ts index ccf2fca7a..848e1b9a6 100644 --- a/lib/OnyxCache.ts +++ b/lib/OnyxCache.ts @@ -227,7 +227,7 @@ class OnyxCache { const affectedCollections = new Set(); // Use for-in loop to avoid an unnecessary array allocation from Object.keys() - /* eslint-disable-next-line no-restricted-syntax, guard-for-in */ /* oxlint-disable-next-line core/no-restricted-syntax */ + // oxlint-disable-next-line core/no-restricted-syntax, guard-for-in for (const key in data) { const value = data[key]; this.addKey(key); @@ -298,7 +298,7 @@ class OnyxCache { const affectedCollections = new Set(); // Use for-in loop to avoid an unnecessary array allocation from Object.entries() - /* eslint-disable-next-line no-restricted-syntax, guard-for-in */ /* oxlint-disable-next-line core/no-restricted-syntax */ + // oxlint-disable-next-line core/no-restricted-syntax, guard-for-in for (const key in data) { const value = data[key]; this.addKey(key); @@ -522,7 +522,7 @@ class OnyxCache { // We can't rely on count comparison alone — if one key is removed and another added, // the counts match but the snapshot content is different. if (!hasMemberChanges && previousSnapshot) { - /* eslint-disable-next-line no-restricted-syntax */ /* oxlint-disable-next-line core/no-restricted-syntax */ + // oxlint-disable-next-line core/no-restricted-syntax for (const key in previousSnapshot) { if (!(key in members)) { hasMemberChanges = true; diff --git a/lib/utils.ts b/lib/utils.ts index 8f7ddff8d..41f432057 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -212,7 +212,7 @@ function isEmptyObject(obj: T | EmptyValue): obj is EmptyValue { } // Use for-in loop to avoid an unnecessary array allocation from Object.keys() - /* eslint-disable-next-line no-restricted-syntax */ /* oxlint-disable-next-line core/no-restricted-syntax */ + // oxlint-disable-next-line core/no-restricted-syntax for (const key in obj) { if (Object.hasOwn(obj, key)) { return false; @@ -246,7 +246,7 @@ function needsNormalization(value: unknown): boolean { } // Use for-in loop to avoid an unnecessary array allocation from Object.keys() - /* eslint-disable-next-line no-restricted-syntax, guard-for-in */ /* oxlint-disable-next-line core/no-restricted-syntax */ + // oxlint-disable-next-line core/no-restricted-syntax, guard-for-in for (const key in value) { if (key === ONYX_INTERNALS__REPLACE_OBJECT_MARK) { return true; @@ -288,7 +288,7 @@ function removeNestedNullValues | null>(value: let hasChanged = false; const result: Record = {}; - /* eslint-disable-next-line no-restricted-syntax, guard-for-in */ /* oxlint-disable-next-line core/no-restricted-syntax */ + // oxlint-disable-next-line core/no-restricted-syntax, guard-for-in for (const key in value) { const propertyValue = value[key]; diff --git a/tests/types.ts b/tests/types.ts index 5503f5ed7..d1022e4b2 100644 --- a/tests/types.ts +++ b/tests/types.ts @@ -8,5 +8,5 @@ import type {DeepRecord} from '../lib/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type GenericDeepRecord = DeepRecord; -/* eslint-disable-next-line import/prefer-default-export */ /* oxlint-disable-next-line hosted/prefer-default-export */ +// oxlint-disable-next-line hosted/prefer-default-export export type {GenericDeepRecord}; From 582d06fe7a1f887b1d72147eeb5641c5d3006c1b Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 17:58:06 +0100 Subject: [PATCH 18/43] Harden Oxlint config derivation with checks ported from App review --- config/oxlint/derive.mjs | 93 +++++++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 26 deletions(-) diff --git a/config/oxlint/derive.mjs b/config/oxlint/derive.mjs index 2e37be929..811e0201d 100644 --- a/config/oxlint/derive.mjs +++ b/config/oxlint/derive.mjs @@ -32,8 +32,22 @@ const HOSTABLE_PLUGINS = { const ALWAYS_HOSTED = new Set([ // Oxlint's built-in version skips `export type {...}`, which ESLint reports (tests/types.ts). 'import/prefer-default-export', + // Oxlint's built-in version also reports components defined inside `jest.mock()` factories. + 'react/function-component-definition', + // Oxlint's built-in version reports on the JSX provider instead of the value, so on a different line. + 'react/jsx-no-constructed-context-values', ]); +// @oxlint/migrate warnings that are safe to ignore. Any other warning may mean a preset rule was lost or changed scope, +// so loading stops. Dropped settings are accepted only if derive.mjs restores them (`import/*`; `import/parsers` is not +// needed because Oxlint parses files itself) or if the presets set them to the value ESLint uses by default anyway. +const EXPECTED_WARNINGS = [/^special parser detected: @babel\/eslint-parser$/, /^react\.version "detect"/, /^Added \d+ globals/]; +const DROPPED_SETTINGS_WARNINGS = [/^Settings not migrated/, /^Settings found under a 'files' pattern/]; +const DEFAULT_SETTINGS = {'react.pragma': 'React'}; + +// Oxlint also accepts these prefixes for rules it runs under another plugin name (prefix: ESLint prefix). +const ESLINT_PREFIX_OF = {eslint: '', 'import-x': 'import', typescript: '@typescript-eslint'}; + // typescript-eslint rules that ask for type information, which Oxlint plugins do not get. naming-convention works // with empty placeholders instead (hosted-rules.mjs) as long as Onyx's options do not use the `types` selector. const STUBBED_PARSER_SERVICES = new Set(['@typescript-eslint/naming-convention']); @@ -74,12 +88,39 @@ function splitRuleId(ruleId) { // migrate runs twice. With type information on, it replaces ESLint core rules like dot-notation with their // typescript-eslint versions, which the presets switch off for JS files, where ESLint uses the core rule. The run // without type information reports those core rules as missing in Oxlint, so they run from ESLint's code. +function isExpectedWarning(warning) { + if (EXPECTED_WARNINGS.some((pattern) => pattern.test(warning))) { + return true; + } + if (!DROPPED_SETTINGS_WARNINGS.some((pattern) => pattern.test(warning))) { + return false; + } + const valuesInPresets = (key) => + eslintConfig.flatMap((block) => { + const value = key.split('.').reduce((object, part) => (object && typeof object === 'object' ? object[part] : undefined), block.settings); + return value === undefined ? [] : [value]; + }); + const keys = [...warning.matchAll(/`([^`]+)`/g)].map((match) => match[1]); + return keys.length > 0 && keys.every((key) => key.startsWith('import/') || (key in DEFAULT_SETTINGS && valuesInPresets(key).every((value) => value === DEFAULT_SETTINGS[key]))); +} + +// Oxlint override `files` are plain globs; ESLint also allows AND patterns (nested arrays) and functions. +for (const block of eslintConfig) { + if (block.files?.some((pattern) => typeof pattern !== 'string')) { + throw new Error('A preset block uses `files` that are not plain globs, which Oxlint cannot express'); + } +} + async function runMigrate() { const skipped = new Set(); const run = async (typeAware) => { const skippedHere = new Map(); const reporter = { - addWarning: () => {}, + addWarning: (warning) => { + if (!isExpectedWarning(warning)) { + throw new Error(`@oxlint/migrate: ${warning}\nCheck what it means for the presets' rules, then handle it in config/oxlint/derive.mjs.`); + } + }, getWarnings: () => [], markSkipped: (rule, category) => skippedHere.set(`${rule}\0${category}`, rule), removeSkipped: (rule, category) => skippedHere.delete(`${rule}\0${category}`), @@ -111,12 +152,20 @@ for (const ruleId of [...skipped, ...ALWAYS_HOSTED]) { coreRuleNames.push(name); continue; } - const rule = HOSTABLE_PLUGINS[prefix] ? pluginRules(prefix)[name] : undefined; + const pluginPackage = HOSTABLE_PLUGINS[prefix]; + const rule = pluginPackage ? pluginRules(prefix)[name] : undefined; if (rule && (!rule.meta?.docs?.requiresTypeChecking || STUBBED_PARSER_SERVICES.has(ruleId))) { hostedRuleIds.push(ruleId); continue; } - throw new Error(`Oxlint cannot run ${ruleId} natively or as a JS plugin. Add it to NOT_PORTED or STAND_INS in config/oxlint/derive.mjs.`); + let fix = 'If an installed ESLint plugin has it, add that plugin to HOSTABLE_PLUGINS; otherwise add the rule to NOT_PORTED or STAND_INS'; + if (rule) { + fix = + 'It needs type information, which Oxlint plugins do not get. Add it to NOT_PORTED or STAND_INS, or to STUBBED_PARSER_SERVICES if it only uses types for options the presets do not set'; + } else if (pluginPackage) { + fix = `${pluginPackage} has no such rule. Add it to NOT_PORTED or STAND_INS`; + } + throw new Error(`Oxlint cannot run ${ruleId} natively or as a JS plugin. ${fix} in config/oxlint/derive.mjs.`); } // All hosted rules share the `hosted/` prefix, so two plugins cannot both bring a rule with the same name. @@ -243,9 +292,6 @@ function replayJsPluginRules() { Object.assign(rules, Object.fromEntries(hits)); continue; } - if (block.files.some(Array.isArray)) { - throw new Error('ESLint `files` with nested arrays (AND patterns) cannot be expressed in Oxlint'); - } hits.forEach(([rule]) => scopedRules.add(rule)); overrides.push({files: block.files, rules: Object.fromEntries(hits)}); } @@ -293,27 +339,19 @@ const oxlintConfig = { ], }; -const oxlintSchema = require(path.join(path.dirname(require.resolve('oxlint/package.json')), 'configuration_schema.json')); -const builtInRules = new Set(Object.keys(oxlintSchema.definitions.DummyRuleMap.properties)); -const otherJsPlugins = new Set(oxlintConfig.jsPlugins.map((plugin) => plugin.name).filter((name) => name !== 'core' && name !== 'hosted')); +// The name a rule runs under when ESLint's code runs it (`hosted/` or `core/`), or undefined if it runs as written. +function getRunningName(rule) { + const {prefix, name} = splitRuleId(rule); + const eslintPrefix = prefix in ESLINT_PREFIX_OF ? ESLINT_PREFIX_OF[prefix] : prefix; + return jsPluginRuleFor(eslintPrefix ? `${eslintPrefix}/${name}` : name); +} -// A rule name written by hand must match how Oxlint runs that rule. If a preset update moves a rule between Oxlint's -// built-in version and `hosted/` or `core/`, the old name would be ignored silently, so it fails here instead. +// Oxlint itself rejects rule names it doesn't know. This catches the case it can't: a valid name for a rule that runs +// from ESLint's code under another name here, so the setting would do nothing or run a second copy. function checkRuleName(rule) { - const slash = rule.indexOf('/'); - const plugin = slash === -1 ? '' : rule.slice(0, slash); - const name = rule.slice(slash + 1); - let known; - if (plugin === 'core') { - known = coreRuleNames.includes(name); - } else if (plugin === 'hosted') { - known = hostedNames.includes(name); - } else { - // A built-in name is ignored when ESLint's code runs that rule instead. - known = otherJsPlugins.has(plugin) || (builtInRules.has(rule) && !ALWAYS_HOSTED.has(rule) && !coreRuleNames.includes(rule)); - } - if (!known) { - throw new Error(`oxlint.config.mts sets "${rule}", which is not a rule Oxlint runs here. Check whether it runs as hosted/, core/ or built in.`); + const runningName = getRunningName(rule); + if (runningName) { + throw new Error(`oxlint.config.mts sets "${rule}", but this rule runs as "${runningName}". Use that name instead.`); } } @@ -322,7 +360,10 @@ function checkRuleName(rule) { * @param {Pick} extra * @returns {import('oxlint').OxlintConfig} */ -function extendOxlintConfig({ignorePatterns = [], settings = {}, overrides = []}) { +function extendOxlintConfig({ignorePatterns = [], settings = {}, overrides = [], ...unsupported}) { + if (Object.keys(unsupported).length) { + throw new Error(`extendOxlintConfig does not handle ${Object.keys(unsupported).join(', ')}. Put rules, env and globals in overrides.`); + } overrides.flatMap((override) => Object.keys(override.rules ?? {})).forEach(checkRuleName); return { ...oxlintConfig, From 9de42e92b5a1a92dcce6105b36a0f2da6ff761c2 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 6 Nov 2026 17:58:07 +0100 Subject: [PATCH 19/43] Run ts-node on CommonJS so build:docs does not rely on ESM detection --- tsconfig.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index f735b15f9..ff8656621 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,11 @@ "declaration": true, "outDir": "./dist" }, + "ts-node": { + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node" + } + }, "exclude": ["**/node_modules/**/*", "**/dist/**/*", "tests/types/**/*.ts"] } From bb3968a0ffc368a4abf6bb6fb6c2738d2093c478 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 9 Nov 2026 10:05:05 +0100 Subject: [PATCH 20/43] chore: Bump eslint-config-expensify to 4.0.20 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 194d98d2d..0e8e27665 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "4.0.19", + "eslint-config-expensify": "4.0.20", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", @@ -8505,9 +8505,9 @@ } }, "node_modules/eslint-config-expensify": { - "version": "4.0.19", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-4.0.19.tgz", - "integrity": "sha512-bO1Mm+wOdr6EjHpaJPbaEXkr7TyAkQttlQa5myXysCM8mQRivzPAIPcDIAtWufIoF/AKswlS4zpSrfw0a1UwBw==", + "version": "4.0.20", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-4.0.20.tgz", + "integrity": "sha512-Pbv/nVvTbTTxxYnYuxjKaf/DCfVGJVxP0dnUmxYnO4YLAtsJCO4msNi5bT8PHkSPi/qNM0aFkHPD+L++dMH0mg==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 4a6b1283a..5d7594c79 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "4.0.19", + "eslint-config-expensify": "4.0.20", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", From 66b5faca957a06edc69441240e4753f1b4051a5f Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 9 Nov 2026 10:18:05 +0100 Subject: [PATCH 21/43] chore: Build oxlint config with eslint-config-expensify --- config/oxlint/derive.mjs | 376 ------------------------- config/oxlint/plugins/core-rules.mjs | 14 - config/oxlint/plugins/hosted-rules.mjs | 48 ---- config/oxlint/presets.mjs | 14 - oxlint.config.mts | 14 +- package-lock.json | 30 +- package.json | 3 +- 7 files changed, 34 insertions(+), 465 deletions(-) delete mode 100644 config/oxlint/derive.mjs delete mode 100644 config/oxlint/plugins/core-rules.mjs delete mode 100644 config/oxlint/plugins/hosted-rules.mjs delete mode 100644 config/oxlint/presets.mjs diff --git a/config/oxlint/derive.mjs b/config/oxlint/derive.mjs deleted file mode 100644 index 811e0201d..000000000 --- a/config/oxlint/derive.mjs +++ /dev/null @@ -1,376 +0,0 @@ -// Oxlint's config, generated from the eslint-config-expensify presets (presets.mjs) each time Oxlint runs, so updating -// eslint-config-expensify needs no manual changes. @oxlint/migrate turns each ESLint rule into Oxlint's built-in version. Rules Oxlint has no built-in -// version of run with ESLint's own code through the plugins in ./plugins, with the same settings as in ESLint. -// A rule that cannot run either way must be listed in NOT_PORTED or STAND_INS, otherwise Oxlint stops with an error, -// so no rule is dropped without a decision. The plugin files read their rule lists from this module. -// oxlint.config.mts adds Onyx's own settings in Oxlint format through extendOxlintConfig. -import migrate from '@oxlint/migrate'; -import {builtinRules} from 'eslint/use-at-your-own-risk'; -import {createRequire} from 'node:module'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -import eslintConfig from './presets.mjs'; - -const require = createRequire(import.meta.url); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const expensifyConfigDirectory = path.dirname(require.resolve('eslint-config-expensify', {paths: [repoRoot]})); - -// The ESLint plugins are dependencies of eslint-config-expensify, not of Onyx, so look them up from there first. -function resolvePackage(request) { - return require.resolve(request, {paths: [expensifyConfigDirectory, repoRoot]}); -} - -// Oxlint has built-in plugins with these names, so ESLint's versions of rules it lacks load as `hosted/`. -const HOSTABLE_PLUGINS = { - react: 'eslint-plugin-react', - import: 'eslint-plugin-import', - jsdoc: 'eslint-plugin-jsdoc', - '@typescript-eslint': '@typescript-eslint/eslint-plugin', -}; - -const ALWAYS_HOSTED = new Set([ - // Oxlint's built-in version skips `export type {...}`, which ESLint reports (tests/types.ts). - 'import/prefer-default-export', - // Oxlint's built-in version also reports components defined inside `jest.mock()` factories. - 'react/function-component-definition', - // Oxlint's built-in version reports on the JSX provider instead of the value, so on a different line. - 'react/jsx-no-constructed-context-values', -]); - -// @oxlint/migrate warnings that are safe to ignore. Any other warning may mean a preset rule was lost or changed scope, -// so loading stops. Dropped settings are accepted only if derive.mjs restores them (`import/*`; `import/parsers` is not -// needed because Oxlint parses files itself) or if the presets set them to the value ESLint uses by default anyway. -const EXPECTED_WARNINGS = [/^special parser detected: @babel\/eslint-parser$/, /^react\.version "detect"/, /^Added \d+ globals/]; -const DROPPED_SETTINGS_WARNINGS = [/^Settings not migrated/, /^Settings found under a 'files' pattern/]; -const DEFAULT_SETTINGS = {'react.pragma': 'React'}; - -// Oxlint also accepts these prefixes for rules it runs under another plugin name (prefix: ESLint prefix). -const ESLINT_PREFIX_OF = {eslint: '', 'import-x': 'import', typescript: '@typescript-eslint'}; - -// typescript-eslint rules that ask for type information, which Oxlint plugins do not get. naming-convention works -// with empty placeholders instead (hosted-rules.mjs) as long as Onyx's options do not use the `types` selector. -const STUBBED_PARSER_SERVICES = new Set(['@typescript-eslint/naming-convention']); - -const NOT_PORTED = { - 'no-invalid-this': 'calls sourceCode.getJSDocComment, which Oxlint JS plugins lack; the error drops every JS plugin report in the file', - 'react/jsx-uses-react': 'only marks variables as used; Oxlint no-unused-vars handles JSX itself', - 'react/jsx-uses-vars': 'only marks variables as used; Oxlint no-unused-vars handles JSX itself', - 'react-hooks/component-hook-factories': 'React Compiler check that Oxlint does not implement', - 'react-hooks/config': 'React Compiler check that Oxlint does not implement', - 'react-hooks/gating': 'React Compiler check that Oxlint does not implement', - '@typescript-eslint/prefer-optional-chain': 'needs type information, and the native rule is still in nursery', - 'rulesdir/boolean-conditional-rendering': 'needs type information, and Oxlint has no equivalent', -}; - -// Oxlint built-in rules used instead of ESLint rules it cannot run. unicorn/prefer-at keeps its default options: with -// `checkAllIndexAccess` it reports hundreds of tuple reads like `result[0]` that the ESLint rule allows. -const STAND_INS = { - 'unicorn/prefer-at': 'rulesdir/prefer-at', -}; -const STAND_IN_FOR = Object.fromEntries(Object.entries(STAND_INS).map(([oxlintRule, eslintRule]) => [eslintRule, oxlintRule])); - -// Oxlint's versions of these rules take no options and fail to load with ESLint's. -const DROP_OPTIONS = new Set(['react/jsx-no-duplicate-props', 'react/jsx-no-undef']); - -const TYPESCRIPT_GLOB = /\.(\{[^}]*\b)?[cm]?tsx?\b/; - -function pluginRules(prefix) { - const plugin = require(resolvePackage(HOSTABLE_PLUGINS[prefix])); - return plugin.rules ?? plugin.default?.rules ?? {}; -} - -function splitRuleId(ruleId) { - const slash = ruleId.lastIndexOf('/'); - return slash === -1 ? {prefix: '', name: ruleId} : {prefix: ruleId.slice(0, slash), name: ruleId.slice(slash + 1)}; -} - -// migrate runs twice. With type information on, it replaces ESLint core rules like dot-notation with their -// typescript-eslint versions, which the presets switch off for JS files, where ESLint uses the core rule. The run -// without type information reports those core rules as missing in Oxlint, so they run from ESLint's code. -function isExpectedWarning(warning) { - if (EXPECTED_WARNINGS.some((pattern) => pattern.test(warning))) { - return true; - } - if (!DROPPED_SETTINGS_WARNINGS.some((pattern) => pattern.test(warning))) { - return false; - } - const valuesInPresets = (key) => - eslintConfig.flatMap((block) => { - const value = key.split('.').reduce((object, part) => (object && typeof object === 'object' ? object[part] : undefined), block.settings); - return value === undefined ? [] : [value]; - }); - const keys = [...warning.matchAll(/`([^`]+)`/g)].map((match) => match[1]); - return keys.length > 0 && keys.every((key) => key.startsWith('import/') || (key in DEFAULT_SETTINGS && valuesInPresets(key).every((value) => value === DEFAULT_SETTINGS[key]))); -} - -// Oxlint override `files` are plain globs; ESLint also allows AND patterns (nested arrays) and functions. -for (const block of eslintConfig) { - if (block.files?.some((pattern) => typeof pattern !== 'string')) { - throw new Error('A preset block uses `files` that are not plain globs, which Oxlint cannot express'); - } -} - -async function runMigrate() { - const skipped = new Set(); - const run = async (typeAware) => { - const skippedHere = new Map(); - const reporter = { - addWarning: (warning) => { - if (!isExpectedWarning(warning)) { - throw new Error(`@oxlint/migrate: ${warning}\nCheck what it means for the presets' rules, then handle it in config/oxlint/derive.mjs.`); - } - }, - getWarnings: () => [], - markSkipped: (rule, category) => skippedHere.set(`${rule}\0${category}`, rule), - removeSkipped: (rule, category) => skippedHere.delete(`${rule}\0${category}`), - getSkippedRulesByCategory: () => ({}), - }; - const config = await migrate(eslintConfig, undefined, {reporter, typeAware, jsPlugins: true}); - for (const rule of skippedHere.values()) { - if (typeAware || builtinRules.has(rule)) { - skipped.add(rule); - } - } - return config; - }; - const config = await run(true); - await run(false); - return {config, skipped}; -} - -const {config: migrated, skipped} = await runMigrate(); - -const coreRuleNames = []; -const hostedRuleIds = []; -for (const ruleId of [...skipped, ...ALWAYS_HOSTED]) { - const {prefix, name} = splitRuleId(ruleId); - if (NOT_PORTED[ruleId] || STAND_IN_FOR[ruleId]) { - continue; - } - if (!prefix && builtinRules.has(name)) { - coreRuleNames.push(name); - continue; - } - const pluginPackage = HOSTABLE_PLUGINS[prefix]; - const rule = pluginPackage ? pluginRules(prefix)[name] : undefined; - if (rule && (!rule.meta?.docs?.requiresTypeChecking || STUBBED_PARSER_SERVICES.has(ruleId))) { - hostedRuleIds.push(ruleId); - continue; - } - let fix = 'If an installed ESLint plugin has it, add that plugin to HOSTABLE_PLUGINS; otherwise add the rule to NOT_PORTED or STAND_INS'; - if (rule) { - fix = - 'It needs type information, which Oxlint plugins do not get. Add it to NOT_PORTED or STAND_INS, or to STUBBED_PARSER_SERVICES if it only uses types for options the presets do not set'; - } else if (pluginPackage) { - fix = `${pluginPackage} has no such rule. Add it to NOT_PORTED or STAND_INS`; - } - throw new Error(`Oxlint cannot run ${ruleId} natively or as a JS plugin. ${fix} in config/oxlint/derive.mjs.`); -} - -// All hosted rules share the `hosted/` prefix, so two plugins cannot both bring a rule with the same name. -const hostedNames = hostedRuleIds.map((ruleId) => splitRuleId(ruleId).name); -const duplicateName = hostedNames.find((name, index) => hostedNames.indexOf(name) !== index); -if (duplicateName) { - throw new Error(`Two hosted rules would both be named hosted/${duplicateName}`); -} - -function jsPluginRuleFor(ruleId) { - if (coreRuleNames.includes(ruleId)) { - return `core/${ruleId}`; - } - return hostedRuleIds.includes(ruleId) ? `hosted/${splitRuleId(ruleId).name}` : undefined; -} - -const severityOf = (entry) => (Array.isArray(entry) ? entry[0] : entry); - -function fixRules(rules = {}, isTypeScriptBlock = false) { - const out = {}; - for (const [rule, entry] of Object.entries(rules)) { - if (ALWAYS_HOSTED.has(rule)) { - continue; - } - if (rule.startsWith('rulesdir/')) { - // eslint-plugin-expensify (rulesdir) rules need type information, which Oxlint plugins do not get. - if (STAND_IN_FOR[rule]) { - out[STAND_IN_FOR[rule]] = severityOf(entry); - } else if (severityOf(entry) !== 'off' && !NOT_PORTED[rule]) { - throw new Error(`${rule} is enabled but has no Oxlint stand-in. Add it to NOT_PORTED or STAND_INS in config/oxlint/derive.mjs.`); - } - continue; - } - if (DROP_OPTIONS.has(rule)) { - out[rule] = severityOf(entry); - continue; - } - if (rule === 'import/no-cycle' && Array.isArray(entry)) { - // The preset sets maxDepth to the string "∞" (no limit). Oxlint accepts only a number and has no limit by - // default, so the option is dropped. - const {maxDepth, ...options} = entry[1] ?? {}; - out[rule] = maxDepth === '∞' ? [entry[0], options] : entry; - continue; - } - if (rule === 'no-unused-vars' && isTypeScriptBlock && Array.isArray(entry)) { - // On TS files ESLint uses typescript-eslint's no-unused-vars, which also reports values used only as types. - // Oxlint's rule does that only with this option. - out[rule] = [entry[0], {...entry[1], reportVarsOnlyUsedAsTypes: true}]; - continue; - } - out[rule] = entry; - } - return out; -} - -const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); -const mergeSettings = (target, source) => { - for (const [key, value] of Object.entries(source)) { - target[key] = isObject(value) && isObject(target[key]) ? mergeSettings({...target[key]}, value) : value; - } - return target; -}; - -// ESLint's import rules (run as hosted) read these settings. Oxlint cannot set settings per file pattern, so all -// blocks are merged into one. `import/parsers` is skipped because Oxlint parses the files itself. -function importSettings() { - const settings = {}; - for (const block of eslintConfig) { - const entries = Object.entries(block.settings ?? {}).filter(([key]) => key.startsWith('import/') && key !== 'import/parsers'); - mergeSettings(settings, Object.fromEntries(entries)); - } - return settings; -} - -// migrate adds jsPlugins inside file-pattern overrides, but Oxlint allows them only at the top level. -function hoistedJsPlugins() { - const specifiers = new Set([migrated, ...(migrated.overrides ?? [])].flatMap((block) => block.jsPlugins ?? [])); - specifiers.delete('eslint-plugin-rulesdir'); - return [...specifiers].map((specifier) => ({ - name: specifier.replace(/^eslint-plugin-/, ''), - specifier: resolvePackage(specifier), - })); -} - -// When a later block sets only a severity (like `'warn'`), ESLint keeps the options from the earlier block and -// Oxlint drops them, so the earlier options are copied in. -function replayJsPluginRules() { - const rules = {}; - const overrides = []; - const scopedRules = new Set(); - const lastOptions = new Map(); - const filesKey = (files) => JSON.stringify(files ? [...files].sort() : null); - const withEarlierOptions = (block, target, entry) => { - const [severity, ...options] = Array.isArray(entry) ? entry : [entry]; - if (options.length) { - lastOptions.set(target, {files: filesKey(block.files), options}); - return entry; - } - const earlier = lastOptions.get(target); - if (!earlier || ['off', 0].includes(severity)) { - return entry; - } - if (earlier.files !== filesKey(null) && earlier.files !== filesKey(block.files)) { - throw new Error(`${target} gets options for some files and a bare severity for others; Oxlint cannot express ESLint's merge`); - } - return [severity, ...earlier.options]; - }; - for (const block of eslintConfig) { - const hits = Object.entries(block.rules ?? {}).flatMap(([ruleId, entry]) => { - const target = jsPluginRuleFor(ruleId); - return target ? [[target, withEarlierOptions(block, target, entry)]] : []; - }); - if (!hits.length) { - continue; - } - if (block.ignores) { - throw new Error('ESLint block with both rules and ignores; replaying it is not supported'); - } - if (!block.files) { - const clash = hits.filter(([rule]) => scopedRules.has(rule)).map(([rule]) => rule); - if (clash.length) { - throw new Error(`Unscoped ESLint block after a scoped one sets ${clash.join(', ')}; Oxlint would apply them in a different order`); - } - Object.assign(rules, Object.fromEntries(hits)); - continue; - } - hits.forEach(([rule]) => scopedRules.add(rule)); - overrides.push({files: block.files, rules: Object.fromEntries(hits)}); - } - return {rules, overrides}; -} - -const replayed = replayJsPluginRules(); - -/** @type {import('oxlint').OxlintConfig} */ -const oxlintConfig = { - plugins: migrated.plugins, - jsPlugins: [ - {name: 'core', specifier: fileURLToPath(new URL('plugins/core-rules.mjs', import.meta.url))}, - {name: 'hosted', specifier: fileURLToPath(new URL('plugins/hosted-rules.mjs', import.meta.url))}, - ...hoistedJsPlugins(), - ], - categories: migrated.categories, - options: {typeAware: true}, - env: migrated.env, - globals: migrated.globals, - settings: { - ...migrated.settings, - ...importSettings(), - // ESLint finds the React version itself; Oxlint needs it set. - react: {...migrated.settings?.react, version: require(resolvePackage('react/package.json')).version}, - }, - ignorePatterns: migrated.ignorePatterns, - rules: {...fixRules(migrated.rules), ...replayed.rules}, - overrides: [ - ...(migrated.overrides ?? []).map(({files, plugins, env, globals, rules}) => ({ - files, - ...(plugins ? {plugins} : {}), - ...(env ? {env} : {}), - ...(globals ? {globals} : {}), - ...(rules - ? { - rules: fixRules( - rules, - files.some((file) => TYPESCRIPT_GLOB.test(file)), - ), - } - : {}), - })), - ...replayed.overrides, - ], -}; - -// The name a rule runs under when ESLint's code runs it (`hosted/` or `core/`), or undefined if it runs as written. -function getRunningName(rule) { - const {prefix, name} = splitRuleId(rule); - const eslintPrefix = prefix in ESLINT_PREFIX_OF ? ESLINT_PREFIX_OF[prefix] : prefix; - return jsPluginRuleFor(eslintPrefix ? `${eslintPrefix}/${name}` : name); -} - -// Oxlint itself rejects rule names it doesn't know. This catches the case it can't: a valid name for a rule that runs -// from ESLint's code under another name here, so the setting would do nothing or run a second copy. -function checkRuleName(rule) { - const runningName = getRunningName(rule); - if (runningName) { - throw new Error(`oxlint.config.mts sets "${rule}", but this rule runs as "${runningName}". Use that name instead.`); - } -} - -/** - * Adds Onyx's own ignores, settings and overrides, written in Oxlint format, to the config generated from the presets. - * @param {Pick} extra - * @returns {import('oxlint').OxlintConfig} - */ -function extendOxlintConfig({ignorePatterns = [], settings = {}, overrides = [], ...unsupported}) { - if (Object.keys(unsupported).length) { - throw new Error(`extendOxlintConfig does not handle ${Object.keys(unsupported).join(', ')}. Put rules, env and globals in overrides.`); - } - overrides.flatMap((override) => Object.keys(override.rules ?? {})).forEach(checkRuleName); - return { - ...oxlintConfig, - ignorePatterns: [...(oxlintConfig.ignorePatterns ?? []), ...ignorePatterns], - settings: mergeSettings({...oxlintConfig.settings}, settings), - overrides: [...oxlintConfig.overrides, ...overrides], - }; -} - -export {STUBBED_PARSER_SERVICES, coreRuleNames, extendOxlintConfig, hostedRuleIds, oxlintConfig, pluginRules, splitRuleId}; diff --git a/config/oxlint/plugins/core-rules.mjs b/config/oxlint/plugins/core-rules.mjs deleted file mode 100644 index 57aa17597..000000000 --- a/config/oxlint/plugins/core-rules.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// Oxlint has no native version of these ESLint core rules, so it runs ESLint's own code for them. -import {builtinRules} from 'eslint/use-at-your-own-risk'; - -import {coreRuleNames} from '../derive.mjs'; - -const plugin = { - meta: { - name: 'core', - version: '0.0.1', - }, - rules: Object.fromEntries(coreRuleNames.map((name) => [name, builtinRules.get(name)])), -}; - -export default plugin; diff --git a/config/oxlint/plugins/hosted-rules.mjs b/config/oxlint/plugins/hosted-rules.mjs deleted file mode 100644 index f801ba6f2..000000000 --- a/config/oxlint/plugins/hosted-rules.mjs +++ /dev/null @@ -1,48 +0,0 @@ -// Oxlint has built-in plugins named react, import, jsdoc and typescript, so ESLint's versions of rules Oxlint lacks -// load here under the name `hosted`. -import {STUBBED_PARSER_SERVICES, hostedRuleIds, pluginRules, splitRuleId} from '../derive.mjs'; - -// typescript-eslint rules call getParserServices, which needs type information Oxlint plugins do not get. Called as -// getParserServices(context, true) it accepts a missing TypeScript program, but still reads these two maps and -// context.languageOptions, so they get empty placeholders. -const STUB_PARSER_SERVICES = { - esTreeNodeToTSNodeMap: new Map(), - tsNodeToESTreeNodeMap: new Map(), -}; - -function withStubbedParserServices(rule) { - return { - ...rule, - create(context) { - const sourceCode = context.sourceCode ?? context.getSourceCode(); - // Oxlint makes `parserServices` read-only, so a Proxy cannot replace it; a new object built on top can. - 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 plugin = { - meta: { - name: 'hosted', - version: '0.0.1', - }, - rules: Object.fromEntries( - hostedRuleIds.map((ruleId) => { - const {prefix, name} = splitRuleId(ruleId); - const rule = pluginRules(prefix)[name]; - return [name, STUBBED_PARSER_SERVICES.has(ruleId) ? withStubbedParserServices(rule) : rule]; - }), - ), -}; - -export default plugin; diff --git a/config/oxlint/presets.mjs b/config/oxlint/presets.mjs deleted file mode 100644 index 4b086a4d6..000000000 --- a/config/oxlint/presets.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// The eslint-config-expensify presets Onyx lints with. They stay in ESLint format because that is what the package -// ships and what @oxlint/migrate converts (derive.mjs). Onyx's own ignores, settings and overrides are in -// oxlint.config.mts, in Oxlint format. -import browserConfig from 'eslint-config-expensify/browser'; -import jestConfig from 'eslint-config-expensify/jest'; -import reactConfig from 'eslint-config-expensify/react'; -import scriptsConfig from 'eslint-config-expensify/scripts'; -import typescriptConfig from 'eslint-config-expensify/typescript'; -import prettierConfig from 'eslint-config-prettier'; - -// The jest preset applies to Onyx's test and mock files only. -const JEST_FILES = ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts']; - -export default [...browserConfig, ...reactConfig, ...typescriptConfig, ...jestConfig.map((block) => ({...block, files: JEST_FILES})), ...scriptsConfig, prettierConfig]; diff --git a/oxlint.config.mts b/oxlint.config.mts index bab40b8f9..a090f2ec7 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -1,8 +1,14 @@ -// Rules come from the eslint-config-expensify presets (config/oxlint/presets.mjs), converted on every run by -// config/oxlint/derive.mjs. This file only adds Onyx's own ignores, settings and overrides. -import {extendOxlintConfig} from './config/oxlint/derive.mjs'; +// Rules come from the eslint-config-expensify presets, converted by eslint-config-expensify/oxlint. This file only +// adds Onyx's own ignores, settings and overrides. +import {buildOxlintConfig} from 'eslint-config-expensify/oxlint'; -export default extendOxlintConfig({ +// The jest preset applies to Onyx's test and mock files only. +const JEST_FILES = ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts']; + +export default await buildOxlintConfig({ + presets: ['browser', 'react', 'typescript', {preset: 'jest', files: JEST_FILES}, 'scripts', 'prettier'], + // Onyx keeps oxlint's own exhaustive-deps, as before the switch to the package. + nativeRules: ['react-hooks/exhaustive-deps'], ignorePatterns: [ 'dist/**', 'node_modules/**', diff --git a/package-lock.json b/package-lock.json index 0e8e27665..cdb74b60a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,8 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "4.0.20", - "eslint-config-prettier": "^9.1.0", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#ace7279c74ab2d4a5f1f842af291301e80c4d597", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -4405,9 +4404,9 @@ } }, "node_modules/@oxlint/migrate/node_modules/globals": { - "version": "17.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", - "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "version": "17.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz", + "integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==", "dev": true, "license": "MIT", "engines": { @@ -8506,8 +8505,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-4.0.20.tgz", - "integrity": "sha512-Pbv/nVvTbTTxxYnYuxjKaf/DCfVGJVxP0dnUmxYnO4YLAtsJCO4msNi5bT8PHkSPi/qNM0aFkHPD+L++dMH0mg==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#ace7279c74ab2d4a5f1f842af291301e80c4d597", + "integrity": "sha512-fjrNrkCxYiMUWQiGKbWyctb0i/JOpMNoFQj0NKz0tdQE109PTUPOdSGF8M414I2cFkL5FwyfnS7k245fkLpsaA==", "dev": true, "license": "ISC", "dependencies": { @@ -8516,6 +8515,7 @@ "@typescript-eslint/utils": "^8.44.1", "confusing-browser-globals": "^1.0.11", "eslint": "^9.36.0", + "eslint-config-prettier": "^9.1.2", "eslint-plugin-es": "^4.1.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jest": "^29.0.1", @@ -8529,6 +8529,22 @@ "lodash": "^4.18.1", "typescript-eslint": "^8.61.0", "underscore": "^1.13.8" + }, + "peerDependencies": { + "@oxlint/migrate": ">=1.90.0 <2", + "oxlint": ">=1.90.0 <2", + "oxlint-tsgolint": ">=7.0.2005 <8" + }, + "peerDependenciesMeta": { + "@oxlint/migrate": { + "optional": true + }, + "oxlint": { + "optional": true + }, + "oxlint-tsgolint": { + "optional": true + } } }, "node_modules/eslint-config-expensify/node_modules/ci-info": { diff --git a/package.json b/package.json index 5d7594c79..b0be1193c 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "4.0.20", - "eslint-config-prettier": "^9.1.0", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#ace7279c74ab2d4a5f1f842af291301e80c4d597", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From 5854fc90a4b0b924eaaf805b73e933a378be46eb Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 9 Nov 2026 15:50:18 +0100 Subject: [PATCH 22/43] chore: Upgrade oxlint to 1.92 and use the latest eslint-config-expensify --- package-lock.json | 342 +++++++++++++++++++++++----------------------- package.json | 6 +- 2 files changed, 174 insertions(+), 174 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdb74b60a..914da3a51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@jest/globals": "^29.7.0", "@lwc/eslint-plugin-lwc": "^3.5.0", "@ngneat/falso": "^7.3.0", - "@oxlint/migrate": "1.90.0", + "@oxlint/migrate": "1.92.0", "@react-native-community/eslint-config": "^3.2.0", "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#ace7279c74ab2d4a5f1f842af291301e80c4d597", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#79f1c70176646ab048d108ad6c97fbfa15900e22", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -57,7 +57,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", - "oxlint": "1.90.0", + "oxlint": "1.92.0", "oxlint-tsgolint": "7.0.2005", "prettier": "^2.8.8", "prop-types": "^15.7.2", @@ -3590,9 +3590,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.155.0.tgz", - "integrity": "sha512-A6xv7hDtnTm+xlZ7utn6rRQpRMJHRhVr3gjRGqlx0RUX84mNk0pAsy6YN/wWhuOTkaZhRrrzaLxf0NfbH77j2w==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.156.0.tgz", + "integrity": "sha512-RMq7DKNzEp1T51QKNLTn0NryNEt2IYfh7mZBlIdi2Ju52ltOEDMeIXv0XBpA6dL7j7ChjlcovtGcc1Jv4qMipg==", "cpu": [ "arm" ], @@ -3607,9 +3607,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.155.0.tgz", - "integrity": "sha512-T1wWwH9/JI5g7Z4kEQwDOwhToxdDhRyOTklH+uuNpJ16m3MZFmGl+l2rKc0NHQdbVRG2LTOBsNjpW3h1BWytPA==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.156.0.tgz", + "integrity": "sha512-mwkWKhOBpmh8fWc3m4MiLsmEAtx0YuHmvyo9ff557cnb3QZ2kt2eauSz75wCGIpiZG3gUsJ4WwzEjm4R4/BHig==", "cpu": [ "arm64" ], @@ -3624,9 +3624,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.155.0.tgz", - "integrity": "sha512-ZoRB3qJxJ8DWA41jHja+YeB0lLfjBy4T+Kr15vnN1k65X3zbJt3rPZftEwN4Ju4tmA4K/T9dWWD7Z8ewQLnrVA==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.156.0.tgz", + "integrity": "sha512-6pGR5fUszM9M4eBBapqpGjCRJolFwuCldx8bRQjqXyM09cQysBTWVF2Ttg4UNJVxYSaw5RRINbveCOOCrsuGvA==", "cpu": [ "arm64" ], @@ -3641,9 +3641,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.155.0.tgz", - "integrity": "sha512-vxtL86BtM+IgrpejA86nbTk5j0HKTIMeuaJ1fQciw6p9RfavPtIPjI7gN34owap0yG9txXBaAWpGdxokNwdqIw==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.156.0.tgz", + "integrity": "sha512-M9QghL7uCAqMKjYJv1rDGblfEvkeQu3QzTPd7Uwo16gG3uO7m9ikVQ9OL8FZTnKzU4vbCEby6kTlEkqN+JeEMA==", "cpu": [ "x64" ], @@ -3658,9 +3658,9 @@ } }, "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.155.0.tgz", - "integrity": "sha512-tN6Ru5e0UVnWaIuiH8jEwnvxuxg7FvhWwSd3oe5oDhOD4ObsSr9/N6WfHLjilUWlPPj65ptwrSDH0Y7lzwTzmw==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.156.0.tgz", + "integrity": "sha512-lX0ANxSwAOrpOT7NHB+YsrBqDJ8sOVon5m7GS9eQtkuUeNzQMMGpFsJLsStawTbrdwOF5KDVxgPWcKmt/DIZUQ==", "cpu": [ "x64" ], @@ -3675,9 +3675,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.155.0.tgz", - "integrity": "sha512-h4ozA5n6L6lwolsz9iwunEhKWnIVbmFRots5QxEix0H6nJ5ICpuDa7PuShuhfpIDFe3+Qvyscioyiv+9g+4Syg==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.156.0.tgz", + "integrity": "sha512-iPgYpasRcqk7X3TGncQg7B+6RotDvG8weug0UrgF+FXLf4KmaNDyT91BKONlqbHcNgK9Nryp7AEZGuQn32fP6g==", "cpu": [ "arm" ], @@ -3692,9 +3692,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.155.0.tgz", - "integrity": "sha512-058/+LardpfuUlsKIvBGk/CYoakEVcMgqYx8GgyUZeOxFWTojYLlCc1Gb60/y21v3esDhHidgiY/Zbq9LpBhaQ==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.156.0.tgz", + "integrity": "sha512-sJqJDEZevtkEfoxrXfaRTkSt6Itckr549cIfBbs11UgXv8bFyqQsXTQa6iKm8mTiWYmCDDFJf7TLnqxcbqzPOw==", "cpu": [ "arm" ], @@ -3709,9 +3709,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.155.0.tgz", - "integrity": "sha512-nrOt2Y3xIAzu+lSPzH0864ieO+iCCXgoo9qFlGANE8Xh0cQ+RP8Wstfp+/wXN2YCyb98cAGzX0VruKYw7it/jw==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.156.0.tgz", + "integrity": "sha512-mrQs5OdRiqKskRuWEORPKT6YGIFhrpVvCvL9hqVaIh60EzgR4qjPvSsV6hWZcOfyWHBTbdEvfnDJPh/eQlxPRQ==", "cpu": [ "arm64" ], @@ -3729,9 +3729,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.155.0.tgz", - "integrity": "sha512-KYAayyN5hQbJPtGKtRxlyc8XYmyntOplyXSQzTDrmbz7l/GCrzQbSswvyJFBTH1LGtsZrbXOAfN52i/Kz6kLKg==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.156.0.tgz", + "integrity": "sha512-ZtPqgCU3+wpU7dvX7YbaEiw71r0UdguzuPhcx4PIEZSs4XFqc6F5wXCtNdb6M7m3DMErlxrwosryoXrzYOY/Gg==", "cpu": [ "arm64" ], @@ -3749,9 +3749,9 @@ } }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.155.0.tgz", - "integrity": "sha512-CSuSY5hfjtsBnHXydOj8jiNfS1qHbdPZuPz1YL1cSgK7VmNYP+QlZIbG0BBYYBgX/vYEgZwbG9mBdW4YairZ5w==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.156.0.tgz", + "integrity": "sha512-ZuGRT/adReZZJn3PYNneb1HQIOH0iIZ2fzFgC1/XJYpMQ5KsRKmWIEzy3wEFsUwIOTomCmnmTZ/zbFYo9z9VeA==", "cpu": [ "ppc64" ], @@ -3769,9 +3769,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.155.0.tgz", - "integrity": "sha512-7KCfOQm+Nz2N8trAXbQkal4TQ5756WVEmhm35y816YHvyFnEfVsICsiV5k0pEszNf/fRm4cm2i6i18SJ6/iCWg==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.156.0.tgz", + "integrity": "sha512-T+iWCeAOoEAJpHqr6/pogHIv98g9p8Ctccysm/h962hG4ayX5OL2c0Ih4oYe+JuFndLXy/OASvwMlTPe76sIiw==", "cpu": [ "riscv64" ], @@ -3789,9 +3789,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.155.0.tgz", - "integrity": "sha512-JSlhgoK+/g1TGJI5NuADx2+6syhxnQNLCWLXyO5QLFyetomcr/vIxSdiNlmyAv963YXUzCuwAwRG6npZ5xyJ1A==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.156.0.tgz", + "integrity": "sha512-wL55bI2kVKfwdxScbw60zVgzAqbQbmE5zpSPWPZoZ9RSG8egm6ITE/UWE5mDWlglxz1QElJCcUbpaJCtHD11BQ==", "cpu": [ "riscv64" ], @@ -3809,9 +3809,9 @@ } }, "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.155.0.tgz", - "integrity": "sha512-WFIX+dPokWp64mi434Md3U3lJaTwNxAphvGjpltoyQjGUsdXavOIRrnp9dOS34Hd75HOAwDxAI+YwWOwqmJbtg==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.156.0.tgz", + "integrity": "sha512-+D+JilhLqwrDLODYEl4qtovVwgxKALb23FkAWBiPvgmUWYgjCV83KKuwwxZJM8GCdKlNA1vt3iKtow8HUFjXtg==", "cpu": [ "s390x" ], @@ -3829,9 +3829,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.155.0.tgz", - "integrity": "sha512-g6xSDC/JpbnVOz5uafMGyK3txBVRFMQTx8opv/yHXGxqV3ZMVl3bf7W8XrSnF68dmy3gM4dgReNryuE53+WkeQ==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.156.0.tgz", + "integrity": "sha512-jRfwpN4xKrsEpC+SgrUPY5k0mINYZn7aLrt22I3uQFoumiSgHgsLYgS9f84ntbkvSydH4HiwGuDa2b1XCHaJzw==", "cpu": [ "x64" ], @@ -3849,9 +3849,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.155.0.tgz", - "integrity": "sha512-6/4xlxKlZJWrQ0uwDFPc0H9+xLu7068CKhX3vIDW3SbvP/lVgnVuT5v6dyugeWskWpuxqF/WJ4WKwhxjU3pG2w==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.156.0.tgz", + "integrity": "sha512-P/ajnypgLmxmuRRyCIZcp2o4lOwmCaXYH5+eqDTcCW+MCTI+csKSw/Zc6GB67jZoVa4ey3q1jXEt7S60adBEMQ==", "cpu": [ "x64" ], @@ -3869,9 +3869,9 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.155.0.tgz", - "integrity": "sha512-tltklL0J0XjDxIRTBXWOBhvc2mitcmES45pIxhwDWfxRyqz7Vkeu0NekTZEIxGE4f/lXgq3vZcjjffteQOVorg==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.156.0.tgz", + "integrity": "sha512-WSN6rQZZc9KqgZ4lMfjVDvqKhVOCjey/YEZIPisGXmwBqwgWOzru207dyiH50LHcf1n9LO2oceGk/id6RL9v0g==", "cpu": [ "arm64" ], @@ -3886,9 +3886,9 @@ } }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.155.0.tgz", - "integrity": "sha512-alAb9TzjatUEsOeoncAziUnfdTDxfyswhalFZ9sC6plUgATIUx2NIZ0+bMn6aJ28AduS1OpG/OAisr/YISn4Ig==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.156.0.tgz", + "integrity": "sha512-l21Tz6tjraGBC3JefVeJZ9pojD0fCiB62PydAv+i9nrqJX7z/pDmyDNYLamDr/PRcQ4UQbFwkSnz7NKBB9UMLw==", "cpu": [ "arm64" ], @@ -3903,9 +3903,9 @@ } }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.155.0.tgz", - "integrity": "sha512-yc5cbSfcGmRv/KpxDFuyk8adEBaWd/9HilWvBbQ4bFbus1V2PUh2mGB2Cfq9rqZTqLeiMT8x0skV+LKp9hOMgQ==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.156.0.tgz", + "integrity": "sha512-CUq0ZFl/RODqPiIgfNNSamkpTj5x8AjBTkR/y1DNVIQiTGnmWqjHcn3o08ZW3q/LgkI4g9G9g6UirmmQFvuOZw==", "cpu": [ "ia32" ], @@ -3920,9 +3920,9 @@ } }, "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.155.0.tgz", - "integrity": "sha512-8heq6jGq+rfpTDRDjoQiBHw0maQZf3IXkS99pdHQZPoTL5kyF5qyPS0RidUlSdnWDIqxXb5nux7GBD9cT+imUw==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.156.0.tgz", + "integrity": "sha512-dcpx5Y1x2v/cs7U/q3SZMvmxfyXjVvqC86jMuw2ZZUbHDd7W2cW7uSMQfg3GLVaE68Su3H9xd/Pc4v6QNyeSRg==", "cpu": [ "x64" ], @@ -3937,9 +3937,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", - "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { @@ -4031,9 +4031,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.90.0.tgz", - "integrity": "sha512-sgfalb1HuxYjiAa4wESZ626z1FGusqNcXYHyB5oIOEprS2sbPg0UYEfo83uF1GNG70DIfZxHkXuE6KFrlSdn8Q==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.92.0.tgz", + "integrity": "sha512-k3Sd4ixQfiOrybv1gT+AMI81HSdtAFsiCFx+xXxWdFkmKW2HNrSiOfwn20o5nl/ggwbzU1oNLDeU/fRPinTVZQ==", "cpu": [ "arm" ], @@ -4048,9 +4048,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.90.0.tgz", - "integrity": "sha512-RcKhz1ETMq+fazE9TKS2+5NIRGwigm/nW8egqF0LI+A8Yw2iW5r0E/C3XAPRiwIm/vcPiutIRIa6IQCIKhZsTQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.92.0.tgz", + "integrity": "sha512-OZzgEuiTSG11QkBWdXEguiLOT6lL09KtfcC/BmvUQWBAjU5eVyBxIAsmWAXv8L85/eT6RE0GBEWTEFBAbYE74g==", "cpu": [ "arm64" ], @@ -4065,9 +4065,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.90.0.tgz", - "integrity": "sha512-PSrxMvP6mx+p1karZcUxLdy9lpaP+JWQPti4al9FgTaPhxE+pT/BjhGExloVVSWgZgIfOlLOjVyyq8BXlodhLQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.92.0.tgz", + "integrity": "sha512-We6XS7tIqxgUoany6uyGjj7mNsh/mwf4Gt9gUzhxG65NKo2Ow9s6tDfrVuI0FVg8n3YmpW5PBx80qFxiLwAfsA==", "cpu": [ "arm64" ], @@ -4082,9 +4082,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.90.0.tgz", - "integrity": "sha512-OLbU9qsrV2Q8QxT2OEzOPdO8zCCpyI8qB+hDI8yr6RsYELtkf0FAZhJdfjM/RnLHKzt44HWwAPnk92vBtKofvw==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.92.0.tgz", + "integrity": "sha512-jKJWijmi6AxAZcXXWA6R/NLVZHxoRTbIMnV8znsHzeiVfuEefYcyR8SJGfbBPmkG+kveLFRcBGw0fBrjLqtGmQ==", "cpu": [ "x64" ], @@ -4099,9 +4099,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.90.0.tgz", - "integrity": "sha512-fBA4pWyVDARmNucQqpyFuPC49xYf1r+sX7dxPL5fNPQrSMLum39Sn56tZ9JPleWC7FzLPwSQ7plYYfirYBHF8A==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.92.0.tgz", + "integrity": "sha512-wkw01wvJPM8/4jh+MV3cp11859VuBc94N2q5uOzcBgdGIRAqMCg1O0W/+xsZ1fsInvuUcv2Xo4nd1nzcKhi9kg==", "cpu": [ "x64" ], @@ -4116,9 +4116,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.90.0.tgz", - "integrity": "sha512-zhxH78eP2nR6/pGdhBc7MIfjhhcgATeu8fkmkFJbuzfk6Cj0FzBxyEhL85yDqrCybjx+G2YDzZGsxIO9kvO4Iw==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.92.0.tgz", + "integrity": "sha512-Elm6nW308MzmR8knvk0tPqQ4XE+PGKztjXv7kVZTRLkhBQZRXHUQoBLc4E30fiIuF6gkZO5f3EpJtQc4Ok/fXw==", "cpu": [ "arm" ], @@ -4133,9 +4133,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.90.0.tgz", - "integrity": "sha512-MvYmpHyJqUJjEvkZbJHdwfdL45cGn8EVBkYmoXFiaYFW9suoD3dbJl5mBdpy0DVgdjlyddmldbRtk3cCgETadQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.92.0.tgz", + "integrity": "sha512-oylpBAoeogddjhTlSPsXrzGq/aeJsvaULz/hYXvWtGn/Wt8LPLC7gFhucCicbRnzEKsShrZxIA0KyHmtFNnijQ==", "cpu": [ "arm" ], @@ -4150,9 +4150,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.90.0.tgz", - "integrity": "sha512-L13Sy+Kg0nZhwTsToniqjUFPA2TuM6962qCZ5Wp5KeB517Tw7ApdLTTtlb+bF86fcZZa2EtvCVvAJxk4Dya/MQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.92.0.tgz", + "integrity": "sha512-/EWOI0wwY+74UGvC93TldpoNP8moIcidVUPbUSoX16/SEdF7ah209yxSQia4+9JdCRWYA3+yQMofXMgWKk/16Q==", "cpu": [ "arm64" ], @@ -4170,9 +4170,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.90.0.tgz", - "integrity": "sha512-wFDdZWGpzIPFdJFjSLWnKB0TGzFriTKUIrl04iMqMyM9BBIJTuerfCHYa0WpDSjr5x3lcW/CHLyacSt886WpGg==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.92.0.tgz", + "integrity": "sha512-9Se7Zuazr104yc2PaluriJW2rqvtaN6FSi9MSjQgZGeYb6LKvEcJ586tpjek0l7hNQNoir8MID7OENPH6juN1w==", "cpu": [ "arm64" ], @@ -4190,9 +4190,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.90.0.tgz", - "integrity": "sha512-FRywfn+MoKn8xpBSzgNwNVliSKRI2QXkxi/iF7o6FEQs8Bh8z2RpVTaFAcO3pchXjvB2eGNFIoLLQABIQ+u7VA==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.92.0.tgz", + "integrity": "sha512-88IAchs204I/xxAbAo3NRyz1m/29tn8jUAPvc4tK4FzrT2PLwZertd93ANF6231vwbAhYiD5gl0cX8Po5xsjWQ==", "cpu": [ "ppc64" ], @@ -4210,9 +4210,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.90.0.tgz", - "integrity": "sha512-IS2PaUgpc/qeLADTyhUNmdx3x4ijs9pjJdPKBYHaT+mAE7k3FsjnHPTjhpsak1b++h6CIPhv6WX9Z+y7Pw0voQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.92.0.tgz", + "integrity": "sha512-KtFdVdkfvBGcsrmnbjYJ9/XTDABnaniiwnptAcAm0pkaR2zhZ3sr/Xy+N1Xbz1Yq5Jegu7SoeNv9caTP0b5OTA==", "cpu": [ "riscv64" ], @@ -4230,9 +4230,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.90.0.tgz", - "integrity": "sha512-s7JFKRkGS8O7i2ZVODfuO++NC7sH6HDqpMn8lYxw91w0SXJOxnAVjQ8utvmDwLMMT0jaSkuOKuUYJDGDtu4sHQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.92.0.tgz", + "integrity": "sha512-V43CO5FPgHtwHup4H5+dIdwzZX2FhREQGsSBZx8V1UyFEAul0Q13qviKqZfSMM6G41CGtCekVpJGGOxW5v1y8Q==", "cpu": [ "riscv64" ], @@ -4250,9 +4250,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.90.0.tgz", - "integrity": "sha512-b0GxrOQr5VONCkd7KLSqmP6b6nqZuTpuS8QPQHzltmqdXP5bY0Jl0CZ0QmlCTrS3ExaLYbioFYOARzNbYvcApQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.92.0.tgz", + "integrity": "sha512-WooxeACzGH5q34olo7JgAk7XbVkTUNfwhR/TzmhwVs/y1p2R7ZWJo14hiyxiGcoYZR1iBnbBFHectADqb/KSog==", "cpu": [ "s390x" ], @@ -4270,9 +4270,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.90.0.tgz", - "integrity": "sha512-iaJeShJtgUp8rZbt9YUdTAGOpmX5KogCLba17Jp1tWdJ3F4M3D2g2TKUyI1FMTHxKH9NNHuQcuC2kZRg6IV7yQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.92.0.tgz", + "integrity": "sha512-lW2pLF1oFvNN780YUCtDyoUm8oFzZGJNcOFe9RtLMX5v5+FcWfAQvHUMMPF8H8kJB0HAi3dFmWDWLa5VPuY9mw==", "cpu": [ "x64" ], @@ -4290,9 +4290,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.90.0.tgz", - "integrity": "sha512-/aZoXnL5WBLCXuzfHqvEFXcm4iwYmr/cYByHDTXzLFzJdE5DC7bm6phpWqHv6BeqpVgY9IpBAxGDv2WEkl5JCg==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.92.0.tgz", + "integrity": "sha512-2BJAAB/eqPn7iD7mFVfs+1ZVUogAYOMCTaUgjAvbtNGg2ppCDE7gcv/9VSO6GCDybdFUqlcyeyjn9hRpl1ZHog==", "cpu": [ "x64" ], @@ -4310,9 +4310,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.90.0.tgz", - "integrity": "sha512-SMhk7ozwoxYDKfYqGZCQEfULOKDneF9uhaoIf2neoDLWvNtjY9yVeL7YHmE5L9WdKvMpWNwwZxhenxBu6AgfOg==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.92.0.tgz", + "integrity": "sha512-iLtjNqqa/2IV5qu/3rrkpVd42Ia3yeCn4fSZoEr+m5FoGHtZvAV6J6YuU3lewg5Rg5ORGvwhdgN+7gNzwkl7tA==", "cpu": [ "arm64" ], @@ -4327,9 +4327,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.90.0.tgz", - "integrity": "sha512-KBvR3A9fO7fCi2vCP8ifWvlia7tGEQV9NaYh+IGwa/7Groy48Mt/UaYqMK9/zOtAWl20BUCf6RPqzJ32fRw33Q==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.92.0.tgz", + "integrity": "sha512-ryxiR3ZqB0OJzydMu+bVC3R7ZtoU7X1rI8sEgTBCCvQvgVwoCwqSFlfmhgiZ+9wZ4IdTsFeu9tdiF9KtRCbSHQ==", "cpu": [ "arm64" ], @@ -4344,9 +4344,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.90.0.tgz", - "integrity": "sha512-+x42rDHQGbTCuT3EzpWAzhP3NoVPE/cJsG4gnrakOSg0olV+GCdMpPU23Nvrl3zZMnXxFZdTtDv4D3SuaA5ZeQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.92.0.tgz", + "integrity": "sha512-DaE9OChj4nsdEST32BP7saGu8WtjOinTy/eS88dZ0TvTfDV26ebRDhsI1rL9OKXfO+DgehzhAQMqdPr2jgfcFw==", "cpu": [ "ia32" ], @@ -4361,9 +4361,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.90.0.tgz", - "integrity": "sha512-30JiaFEBUB9peEOcvVM8ggo88SoN/IdSr9DW505JdBji/R6dM66gKEiG65hw6qcaF2f4mO0cEJ+RwRZprmun9g==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.92.0.tgz", + "integrity": "sha512-0AyhqX0ciFuEnHIL+B4PIMMX3s/IpGHNEbbUqwsNhSrzWUSlEOPw/q7qFnRsEJfiKedcqVfUE/aXYuSMtpiFyw==", "cpu": [ "x64" ], @@ -4378,15 +4378,15 @@ } }, "node_modules/@oxlint/migrate": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/@oxlint/migrate/-/migrate-1.90.0.tgz", - "integrity": "sha512-NfilzgljTDkgH6CAiHo/joWNZ6/4SKxbS74HW2rpWXVn8Wc/qcOz9b8u0LaFbNLp1ADl9XF2BVsUC5DzCKFwjQ==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/@oxlint/migrate/-/migrate-1.92.0.tgz", + "integrity": "sha512-QqdP+mFFYD70qjHxFKKbIE2T4p2e+EEJGGv2tzdF313vefgcz9h7QLo6ijIkTWe6Bt9LQUywiuD5jhuq3OdpHA==", "dev": true, "license": "MIT", "dependencies": { "commander": "^15.0.0", "globals": "^17.0.0", - "oxc-parser": "^0.155.0", + "oxc-parser": "^0.156.0", "tinyglobby": "^0.2.14" }, "bin": { @@ -8505,8 +8505,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#ace7279c74ab2d4a5f1f842af291301e80c4d597", - "integrity": "sha512-fjrNrkCxYiMUWQiGKbWyctb0i/JOpMNoFQj0NKz0tdQE109PTUPOdSGF8M414I2cFkL5FwyfnS7k245fkLpsaA==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#79f1c70176646ab048d108ad6c97fbfa15900e22", + "integrity": "sha512-Hla0716nmFPUBi2j5j8DdejfjXsPob76DGcVo5G1WsJlydYRdjnVGjrA37+PbUkLsSYidNKigZSHh4c938r1Yg==", "dev": true, "license": "ISC", "dependencies": { @@ -14069,13 +14069,13 @@ } }, "node_modules/oxc-parser": { - "version": "0.155.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.155.0.tgz", - "integrity": "sha512-C/ReSf1JUFukK/zvk6jhcNibSssvas5+ktIzK7fbKVd3CedWe59C3QwHEOrEZj/4Rt9+xg0Jeis/Jv1F/KM1mw==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.156.0.tgz", + "integrity": "sha512-7JhjgOfZjrIFJH5TlHrpRua864Si5GvwrPr5+eIRCY2sszhKXo6pyhP0HQKOGTtfO4UowwZ72XTGba0+lZphOQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "^0.155.0" + "@oxc-project/types": "^0.156.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -14084,31 +14084,31 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.155.0", - "@oxc-parser/binding-android-arm64": "0.155.0", - "@oxc-parser/binding-darwin-arm64": "0.155.0", - "@oxc-parser/binding-darwin-x64": "0.155.0", - "@oxc-parser/binding-freebsd-x64": "0.155.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.155.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.155.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.155.0", - "@oxc-parser/binding-linux-arm64-musl": "0.155.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.155.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.155.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.155.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.155.0", - "@oxc-parser/binding-linux-x64-gnu": "0.155.0", - "@oxc-parser/binding-linux-x64-musl": "0.155.0", - "@oxc-parser/binding-openharmony-arm64": "0.155.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.155.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.155.0", - "@oxc-parser/binding-win32-x64-msvc": "0.155.0" + "@oxc-parser/binding-android-arm-eabi": "0.156.0", + "@oxc-parser/binding-android-arm64": "0.156.0", + "@oxc-parser/binding-darwin-arm64": "0.156.0", + "@oxc-parser/binding-darwin-x64": "0.156.0", + "@oxc-parser/binding-freebsd-x64": "0.156.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.156.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.156.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.156.0", + "@oxc-parser/binding-linux-arm64-musl": "0.156.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.156.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.156.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.156.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.156.0", + "@oxc-parser/binding-linux-x64-gnu": "0.156.0", + "@oxc-parser/binding-linux-x64-musl": "0.156.0", + "@oxc-parser/binding-openharmony-arm64": "0.156.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.156.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.156.0", + "@oxc-parser/binding-win32-x64-msvc": "0.156.0" } }, "node_modules/oxlint": { - "version": "1.90.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.90.0.tgz", - "integrity": "sha512-RkTN9EmgIUpLuhNoLhO3OliMGJlvb3Dk7XVjBZnfoMCHrVpuz5ESd97klfiyYeV7XfA2lNLnDCPMkUI+Qw5How==", + "version": "1.92.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.92.0.tgz", + "integrity": "sha512-y9iRiRwVjVFqzKNXZJ9bT/GYRdf+F9Gy+a1Yju2Aq3xqqa4FJYXHI8VrxlpzxKDvkYRohvJX42m/eH+qkQPOFw==", "dev": true, "license": "MIT", "bin": { @@ -14121,25 +14121,25 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.90.0", - "@oxlint/binding-android-arm64": "1.90.0", - "@oxlint/binding-darwin-arm64": "1.90.0", - "@oxlint/binding-darwin-x64": "1.90.0", - "@oxlint/binding-freebsd-x64": "1.90.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.90.0", - "@oxlint/binding-linux-arm-musleabihf": "1.90.0", - "@oxlint/binding-linux-arm64-gnu": "1.90.0", - "@oxlint/binding-linux-arm64-musl": "1.90.0", - "@oxlint/binding-linux-ppc64-gnu": "1.90.0", - "@oxlint/binding-linux-riscv64-gnu": "1.90.0", - "@oxlint/binding-linux-riscv64-musl": "1.90.0", - "@oxlint/binding-linux-s390x-gnu": "1.90.0", - "@oxlint/binding-linux-x64-gnu": "1.90.0", - "@oxlint/binding-linux-x64-musl": "1.90.0", - "@oxlint/binding-openharmony-arm64": "1.90.0", - "@oxlint/binding-win32-arm64-msvc": "1.90.0", - "@oxlint/binding-win32-ia32-msvc": "1.90.0", - "@oxlint/binding-win32-x64-msvc": "1.90.0" + "@oxlint/binding-android-arm-eabi": "1.92.0", + "@oxlint/binding-android-arm64": "1.92.0", + "@oxlint/binding-darwin-arm64": "1.92.0", + "@oxlint/binding-darwin-x64": "1.92.0", + "@oxlint/binding-freebsd-x64": "1.92.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.92.0", + "@oxlint/binding-linux-arm-musleabihf": "1.92.0", + "@oxlint/binding-linux-arm64-gnu": "1.92.0", + "@oxlint/binding-linux-arm64-musl": "1.92.0", + "@oxlint/binding-linux-ppc64-gnu": "1.92.0", + "@oxlint/binding-linux-riscv64-gnu": "1.92.0", + "@oxlint/binding-linux-riscv64-musl": "1.92.0", + "@oxlint/binding-linux-s390x-gnu": "1.92.0", + "@oxlint/binding-linux-x64-gnu": "1.92.0", + "@oxlint/binding-linux-x64-musl": "1.92.0", + "@oxlint/binding-openharmony-arm64": "1.92.0", + "@oxlint/binding-win32-arm64-msvc": "1.92.0", + "@oxlint/binding-win32-ia32-msvc": "1.92.0", + "@oxlint/binding-win32-x64-msvc": "1.92.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2005", diff --git a/package.json b/package.json index b0be1193c..d5f9399e5 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@jest/globals": "^29.7.0", "@lwc/eslint-plugin-lwc": "^3.5.0", "@ngneat/falso": "^7.3.0", - "@oxlint/migrate": "1.90.0", + "@oxlint/migrate": "1.92.0", "@react-native-community/eslint-config": "^3.2.0", "@react-native/babel-preset": "0.76.3", "@react-native/polyfills": "^2.0.0", @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#ace7279c74ab2d4a5f1f842af291301e80c4d597", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#79f1c70176646ab048d108ad6c97fbfa15900e22", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -92,7 +92,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", - "oxlint": "1.90.0", + "oxlint": "1.92.0", "oxlint-tsgolint": "7.0.2005", "prettier": "^2.8.8", "prop-types": "^15.7.2", From dbaaa07a7c641ea75d94d215439ae17c6013851a Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 9 Nov 2026 15:54:27 +0100 Subject: [PATCH 23/43] chore: Use the latest eslint-config-expensify build --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 914da3a51..179e8549f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#79f1c70176646ab048d108ad6c97fbfa15900e22", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#cba226899a8c1507cb4541d709bbf6fb92ed6520", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8505,8 +8505,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#79f1c70176646ab048d108ad6c97fbfa15900e22", - "integrity": "sha512-Hla0716nmFPUBi2j5j8DdejfjXsPob76DGcVo5G1WsJlydYRdjnVGjrA37+PbUkLsSYidNKigZSHh4c938r1Yg==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#cba226899a8c1507cb4541d709bbf6fb92ed6520", + "integrity": "sha512-59dRYNBIRQqG1TnVY48GCwGDk0yauKwI/gSMlQlp2tJBZrHyxu4ECO+uBIkxF+Dr+7tEz6rNGZkInMiCDaVwqA==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index d5f9399e5..2a9706cb3 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#79f1c70176646ab048d108ad6c97fbfa15900e22", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#cba226899a8c1507cb4541d709bbf6fb92ed6520", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From 6eece7e65d0d26aa3ef4942f4b67a245ca20e10f Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 9 Nov 2026 16:52:55 +0100 Subject: [PATCH 24/43] chore: Use the latest eslint-config-expensify build --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 179e8549f..a1214692f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#cba226899a8c1507cb4541d709bbf6fb92ed6520", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#238520a834ab54ad13b971447964e44a1f7bb8d9", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8505,8 +8505,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#cba226899a8c1507cb4541d709bbf6fb92ed6520", - "integrity": "sha512-59dRYNBIRQqG1TnVY48GCwGDk0yauKwI/gSMlQlp2tJBZrHyxu4ECO+uBIkxF+Dr+7tEz6rNGZkInMiCDaVwqA==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#238520a834ab54ad13b971447964e44a1f7bb8d9", + "integrity": "sha512-A3yX0BuUem0oLiHBput6WJsY+2rkJneOvn5oMdYs5l7zE1kRD1gV4aCDOzXg0vSnKYn1izDuYNTlSQF2TccpYQ==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 2a9706cb3..1463568e9 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#cba226899a8c1507cb4541d709bbf6fb92ed6520", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#238520a834ab54ad13b971447964e44a1f7bb8d9", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From 38d76c8f63402a748257e6889edf3c9d84db20e4 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 9 Nov 2026 16:53:02 +0100 Subject: [PATCH 25/43] fix: Restore no-unresolved and the underscore exception for TS files --- config/oxlint/oxlint.seatbelt.tsv | 3 --- oxlint.config.mts | 16 ++++++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/config/oxlint/oxlint.seatbelt.tsv b/config/oxlint/oxlint.seatbelt.tsv index 803e458de..b1c4dab63 100644 --- a/config/oxlint/oxlint.seatbelt.tsv +++ b/config/oxlint/oxlint.seatbelt.tsv @@ -30,14 +30,12 @@ "../../lib/OnyxUtils.ts" "typescript/prefer-nullish-coalescing" 3 "../../lib/StateMachine.ts" "typescript/no-unsafe-type-assertion" 2 "../../lib/storage/__mocks__/index.ts" "typescript/no-unsafe-argument" 1 -"../../lib/storage/InstanceSync/index.ts" "no-unused-vars" 1 "../../lib/storage/InstanceSync/index.web.ts" "hosted/no-types" 1 "../../lib/storage/InstanceSync/index.web.ts" "typescript/no-unsafe-type-assertion" 1 "../../lib/storage/InstanceSync/index.web.ts" "typescript/restrict-template-expressions" 1 "../../lib/storage/providers/IDBKeyValProvider/index.ts" "typescript/no-unsafe-return" 1 "../../lib/storage/providers/IDBKeyValProvider/index.ts" "typescript/no-unsafe-type-assertion" 3 "../../lib/storage/providers/MemoryOnlyProvider.ts" "typescript/no-unsafe-type-assertion" 5 -"../../lib/storage/providers/NoopProvider.ts" "no-unused-vars" 1 "../../lib/storage/providers/NoopProvider.ts" "typescript/no-unsafe-type-assertion" 1 "../../lib/storage/providers/SQLiteProvider.ts" "hosted/naming-convention" 4 "../../lib/storage/providers/SQLiteProvider.ts" "typescript/no-unsafe-assignment" 1 @@ -118,7 +116,6 @@ "../../tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "typescript/no-unsafe-type-assertion" 4 "../../tests/unit/storage/providers/IDBKeyvalProviderTest.ts" "typescript/unbound-method" 2 "../../tests/unit/storage/providers/SQLiteProviderTest.ts" "hosted/naming-convention" 3 -"../../tests/unit/storage/providers/SQLiteProviderTest.ts" "no-unused-vars" 1 "../../tests/unit/storage/providers/SQLiteProviderTest.ts" "typescript/no-non-null-assertion" 8 "../../tests/unit/storage/providers/SQLiteProviderTest.ts" "typescript/no-unnecessary-type-assertion" 7 "../../tests/unit/storage/providers/SQLiteProviderTest.ts" "typescript/no-unsafe-member-access" 2 diff --git a/oxlint.config.mts b/oxlint.config.mts index a090f2ec7..c347bfd92 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -1,5 +1,7 @@ -// Rules come from the eslint-config-expensify presets, converted by eslint-config-expensify/oxlint. This file only -// adds Onyx's own ignores, settings and overrides. +// Rules come from eslint-config-expensify's presets, converted to oxlint by buildOxlintConfig. Onyx leaves out the +// expensify preset on purpose. The presets don't match the rules in the old eslint.config.mjs: some rules it turned +// off are on now (their findings are in config/oxlint/oxlint.seatbelt.tsv), and a few it turned on are not set. +// Below are Onyx's own ignores, settings and rule overrides. import {buildOxlintConfig} from 'eslint-config-expensify/oxlint'; // The jest preset applies to Onyx's test and mock files only. @@ -38,6 +40,16 @@ export default await buildOxlintConfig({ 'hosted/require-default-props': 'off', }, }, + { + files: ['**/*.ts', '**/*.tsx'], + rules: { + // The old ESLint config checked imports in TS files, and oxlint has no rule of its own for it. + 'hosted/no-unresolved': 'error', + // A `_` prefix marks parameters kept on purpose, as the old ESLint config allowed. The other options + // repeat the typescript preset, because an override replaces them. + 'no-unused-vars': ['error', {vars: 'all', args: 'after-used', argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, reportVarsOnlyUsedAsTypes: true}], + }, + }, { files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], env: { From e94e512329c6a5abf9eb75111c54402550bde819 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 9 Nov 2026 16:53:02 +0100 Subject: [PATCH 26/43] chore: Lint the oxlint config files --- config/oxlint/seatbelt.test.mjs | 8 ++++++-- oxlint.config.mts | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/config/oxlint/seatbelt.test.mjs b/config/oxlint/seatbelt.test.mjs index d0bfd1d2d..8eb1ffb82 100644 --- a/config/oxlint/seatbelt.test.mjs +++ b/config/oxlint/seatbelt.test.mjs @@ -129,13 +129,17 @@ describe('serializeSeatbeltTSV', () => { describe('applySeatbelt', () => { const dirs = []; afterEach(() => { - dirs.splice(0).forEach((dir) => fs.rmSync(dir, {recursive: true, force: true})); + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, {recursive: true, force: true}); + } }); function setup(tsv, files = ['src.ts']) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-seatbelt-')); dirs.push(dir); - files.forEach((file) => fs.writeFileSync(path.join(dir, file), 'x\n')); + for (const file of files) { + fs.writeFileSync(path.join(dir, file), 'x\n'); + } const tsvPath = path.join(dir, 'oxlint.seatbelt.tsv'); fs.writeFileSync(tsvPath, tsv); return {dir, tsvPath, file: (name) => path.join(dir, name)}; diff --git a/oxlint.config.mts b/oxlint.config.mts index c347bfd92..ef58ed718 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -21,8 +21,6 @@ export default await buildOxlintConfig({ 'cpp/**', 'bench-results/**', '.github/**', - 'oxlint.config.mts', - 'config/oxlint/**', ], settings: { 'import/resolver': { @@ -50,6 +48,22 @@ export default await buildOxlintConfig({ 'no-unused-vars': ['error', {vars: 'all', args: 'after-used', argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, reportVarsOnlyUsedAsTypes: true}], }, }, + // Node tooling that runs on the current Node, so the old-browser `es/*` rules do not apply. Rule names are + // quoted keys, `.mjs` imports need their extension in Node, and the lint CLI prints its result with console. + { + files: ['oxlint.config.mts', 'config/oxlint/**/*'], + env: { + node: true, + }, + rules: { + 'es/no-nullish-coalescing-operators': 'off', + 'es/no-optional-chaining': 'off', + 'hosted/naming-convention': 'off', + 'hosted/no-extraneous-dependencies': 'off', + 'import/extensions': 'off', + 'no-console': 'off', + }, + }, { files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], env: { From 6679091351c56aeea0d2b48a74e7264c60b21177 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 9 Nov 2026 17:07:05 +0100 Subject: [PATCH 27/43] style: Format oxlint.config.mts with prettier --- oxlint.config.mts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/oxlint.config.mts b/oxlint.config.mts index ef58ed718..cf694327f 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -11,17 +11,7 @@ export default await buildOxlintConfig({ presets: ['browser', 'react', 'typescript', {preset: 'jest', files: JEST_FILES}, 'scripts', 'prettier'], // Onyx keeps oxlint's own exhaustive-deps, as before the switch to the package. nativeRules: ['react-hooks/exhaustive-deps'], - ignorePatterns: [ - 'dist/**', - 'node_modules/**', - '**/*.d.ts', - '**/*.config.js', - '**/*.config.cjs', - 'tests/types/**/*.ts', - 'cpp/**', - 'bench-results/**', - '.github/**', - ], + ignorePatterns: ['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**'], settings: { 'import/resolver': { typescript: { From ea8f2a038fb74c328a4bdce8e3c9fb7652edb73b Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 10 Nov 2026 13:09:05 +0100 Subject: [PATCH 28/43] chore: Bump eslint-config-expensify to 3561252 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1214692f..54bba4709 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#238520a834ab54ad13b971447964e44a1f7bb8d9", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#3561252c566572bb50e92b902f7c96d6a159ae8a", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8505,8 +8505,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#238520a834ab54ad13b971447964e44a1f7bb8d9", - "integrity": "sha512-A3yX0BuUem0oLiHBput6WJsY+2rkJneOvn5oMdYs5l7zE1kRD1gV4aCDOzXg0vSnKYn1izDuYNTlSQF2TccpYQ==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#3561252c566572bb50e92b902f7c96d6a159ae8a", + "integrity": "sha512-k3z4kQpZDnBWDwW680wcdQIvooXK3f8fvX1q1OARZMPA8qa9tqBQTSSnFIp5wLFjFGPMX71LyXEt/kXR2X/VAQ==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 1463568e9..2b2cee6c2 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#238520a834ab54ad13b971447964e44a1f7bb8d9", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#3561252c566572bb50e92b902f7c96d6a159ae8a", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From a2e2aac80bf26ed454e209601c9be5ed0ba94984 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 10 Nov 2026 15:39:08 +0100 Subject: [PATCH 29/43] chore: Bump eslint-config-expensify to 8dddd5a and add oxc-parser --- package-lock.json | 11 ++++++++--- package.json | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 54bba4709..c3cb83c42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#3561252c566572bb50e92b902f7c96d6a159ae8a", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#8dddd5a9579e0f89368a807775cd70c93b01cd1c", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -57,6 +57,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", + "oxc-parser": "0.156.0", "oxlint": "1.92.0", "oxlint-tsgolint": "7.0.2005", "prettier": "^2.8.8", @@ -8505,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#3561252c566572bb50e92b902f7c96d6a159ae8a", - "integrity": "sha512-k3z4kQpZDnBWDwW680wcdQIvooXK3f8fvX1q1OARZMPA8qa9tqBQTSSnFIp5wLFjFGPMX71LyXEt/kXR2X/VAQ==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#8dddd5a9579e0f89368a807775cd70c93b01cd1c", + "integrity": "sha512-ipOHXnRTXVgB2lEELJ7QXr55xr/obMEL2QU9gUpsRTdq2f/mDGqpV+mUGrYh4rllIuSwdgzaMToHFZjKS1xkuw==", "dev": true, "license": "ISC", "dependencies": { @@ -8532,6 +8533,7 @@ }, "peerDependencies": { "@oxlint/migrate": ">=1.90.0 <2", + "oxc-parser": ">=0.147.0 <1", "oxlint": ">=1.90.0 <2", "oxlint-tsgolint": ">=7.0.2005 <8" }, @@ -8539,6 +8541,9 @@ "@oxlint/migrate": { "optional": true }, + "oxc-parser": { + "optional": true + }, "oxlint": { "optional": true }, diff --git a/package.json b/package.json index 2b2cee6c2..c04a86c42 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#3561252c566572bb50e92b902f7c96d6a159ae8a", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#8dddd5a9579e0f89368a807775cd70c93b01cd1c", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -92,6 +92,7 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", + "oxc-parser": "0.156.0", "oxlint": "1.92.0", "oxlint-tsgolint": "7.0.2005", "prettier": "^2.8.8", From d25f294f9388d8e54ddb1d544672fae08ec6e90a Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 10 Nov 2026 16:30:36 +0100 Subject: [PATCH 30/43] chore: Bump eslint-config-expensify to 312d582 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c3cb83c42..8595c9489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#8dddd5a9579e0f89368a807775cd70c93b01cd1c", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#312d58239c148299146759536b7f2b1bcbaab797", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#8dddd5a9579e0f89368a807775cd70c93b01cd1c", - "integrity": "sha512-ipOHXnRTXVgB2lEELJ7QXr55xr/obMEL2QU9gUpsRTdq2f/mDGqpV+mUGrYh4rllIuSwdgzaMToHFZjKS1xkuw==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#312d58239c148299146759536b7f2b1bcbaab797", + "integrity": "sha512-McAwtFdeKYBLfD7fqGsHDM366Hq6zkGln2Bm+yFxwmh4SGAONleZVUjXzH0pu6fiQCHsmKqKQxewbEEIkmfRSA==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index c04a86c42..a75157186 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#8dddd5a9579e0f89368a807775cd70c93b01cd1c", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#312d58239c148299146759536b7f2b1bcbaab797", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From cc8cd68e0e0febef65d04c133e543fb206278654 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 10 Nov 2026 19:26:07 +0100 Subject: [PATCH 31/43] chore: Bump eslint-config-expensify to c8b9b3c and extend preset rule settings instead of copying them --- config/oxlint/lint.mjs | 7 ++--- oxlint.config.mts | 63 ++++++++++++++++-------------------------- package-lock.json | 6 ++-- package.json | 2 +- 4 files changed, 31 insertions(+), 47 deletions(-) diff --git a/config/oxlint/lint.mjs b/config/oxlint/lint.mjs index a4d0e1e8f..76e0299de 100644 --- a/config/oxlint/lint.mjs +++ b/config/oxlint/lint.mjs @@ -96,8 +96,7 @@ function lint(targets, fix) { return report.diagnostics.map(toLintMessage); } -function formatStylish(messages, showWarnings) { - const errorCount = messages.filter((message) => message.severity === LINT_SEVERITY.ERROR).length; +function formatStylish(messages, errorCount, showWarnings) { const warningCount = messages.length - errorCount; const visible = showWarnings ? messages : messages.filter((message) => message.severity === LINT_SEVERITY.ERROR); const lines = visible.map((message) => { @@ -106,7 +105,7 @@ function formatStylish(messages, showWarnings) { return `${location}\n ${level} ${message.message}${message.ruleID ? ` ${message.ruleID}` : ''}`; }); const summary = `${errorCount} error${errorCount === 1 ? '' : 's'}${showWarnings ? `, ${warningCount} warning${warningCount === 1 ? '' : 's'}` : ''}`; - return {text: [...lines, summary].join('\n\n'), errorCount}; + return [...lines, summary].join('\n\n'); } const {values, positionals} = parseArgs({ @@ -132,6 +131,6 @@ const errorCount = messages.filter((message) => message.severity === LINT_SEVERI if (values.format === 'json') { console.log(JSON.stringify({messages, errorCount, warningCount: messages.length - errorCount})); } else { - console.log(formatStylish(messages, values['show-warnings']).text); + console.log(formatStylish(messages, errorCount, values['show-warnings'])); } process.exitCode = errorCount > 0 ? 1 : 0; diff --git a/oxlint.config.mts b/oxlint.config.mts index cf694327f..aba89dac6 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -2,7 +2,7 @@ // expensify preset on purpose. The presets don't match the rules in the old eslint.config.mjs: some rules it turned // off are on now (their findings are in config/oxlint/oxlint.seatbelt.tsv), and a few it turned on are not set. // Below are Onyx's own ignores, settings and rule overrides. -import {buildOxlintConfig} from 'eslint-config-expensify/oxlint'; +import {buildOxlintConfig, getPresetRuleEntry} from 'eslint-config-expensify/oxlint'; // The jest preset applies to Onyx's test and mock files only. const JEST_FILES = ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts']; @@ -33,9 +33,17 @@ export default await buildOxlintConfig({ rules: { // The old ESLint config checked imports in TS files, and oxlint has no rule of its own for it. 'hosted/no-unresolved': 'error', - // A `_` prefix marks parameters kept on purpose, as the old ESLint config allowed. The other options - // repeat the typescript preset, because an override replaces them. - 'no-unused-vars': ['error', {vars: 'all', args: 'after-used', argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, reportVarsOnlyUsedAsTypes: true}], + // The typescript preset's options, plus a `_` prefix for parameters kept on purpose, as the old ESLint + // config allowed. An override replaces the whole setting, so it starts from the preset's. oxlint's + // no-unused-vars needs reportVarsOnlyUsedAsTypes to report what typescript-eslint's does. + 'no-unused-vars': [ + 'error', + { + ...(await getPresetRuleEntry<[Record]>('typescript', '@typescript-eslint/no-unused-vars'))[1], + argsIgnorePattern: '^_', + reportVarsOnlyUsedAsTypes: true, + }, + ], }, }, // Node tooling that runs on the current Node, so the old-browser `es/*` rules do not apply. Rule names are @@ -48,41 +56,31 @@ export default await buildOxlintConfig({ rules: { 'es/no-nullish-coalescing-operators': 'off', 'es/no-optional-chaining': 'off', - 'hosted/naming-convention': 'off', - 'hosted/no-extraneous-dependencies': 'off', 'import/extensions': 'off', 'no-console': 'off', }, }, + // The presets already leave these off for the .mjs scripts; the config file is TypeScript. + { + files: ['oxlint.config.mts'], + rules: { + 'hosted/naming-convention': 'off', + 'hosted/no-extraneous-dependencies': 'off', + }, + }, { files: ['.prettierrc.cjs', 'jest-sequencer.js', 'jestSetup.js', 'jest-test-environment.ts', 'buildDocs.ts'], env: { node: true, }, }, - // Overriding replaces the whole naming-convention array, so this repeats eslint-config-expensify/typescript - // and allows Onyx key strings (underscores) and numeric collection IDs as object keys. + // The typescript preset's naming-convention, plus Onyx key strings (underscores) and numeric collection IDs as + // object keys in tests. An override replaces the whole setting, so it starts from the preset's. { files: ['tests/**/*'], rules: { 'hosted/naming-convention': [ - 'error', - { - selector: ['variable', 'property'], - format: null, - filter: { - regex: '^__esModule$', - match: true, - }, - }, - { - selector: ['variable', 'property'], - format: ['camelCase', 'UPPER_CASE', 'PascalCase'], - filter: { - regex: '^private_[a-z][a-zA-Z0-9]*$', - match: false, - }, - }, + ...(await getPresetRuleEntry('typescript', '@typescript-eslint/naming-convention')), { selector: 'objectLiteralProperty', format: null, @@ -99,24 +97,11 @@ export default await buildOxlintConfig({ match: true, }, }, - { - selector: 'function', - format: ['camelCase', 'PascalCase'], - }, - { - selector: ['typeLike', 'enumMember'], - format: ['PascalCase'], - }, - { - selector: ['parameter', 'method'], - format: ['camelCase', 'PascalCase'], - leadingUnderscore: 'allow', - }, ], }, }, { - files: ['tests/**/*', 'jestSetup.js', 'lib/**/__mocks__/**/*'], + files: JEST_FILES, rules: { 'import/extensions': 'off', }, diff --git a/package-lock.json b/package-lock.json index 8595c9489..c5b801e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#312d58239c148299146759536b7f2b1bcbaab797", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c8b9b3ca196f238029417d5d5e5736615a421e7d", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#312d58239c148299146759536b7f2b1bcbaab797", - "integrity": "sha512-McAwtFdeKYBLfD7fqGsHDM366Hq6zkGln2Bm+yFxwmh4SGAONleZVUjXzH0pu6fiQCHsmKqKQxewbEEIkmfRSA==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#c8b9b3ca196f238029417d5d5e5736615a421e7d", + "integrity": "sha512-lPNYKSxM7Bl1dc/loFKnBaeyjLjgs09y+HDvXfnca1hAw1vs7RFl67qAM12mwqaQ7zwZMZoN/bWpEwizXuXT6A==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index a75157186..3b9ab8dc0 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#312d58239c148299146759536b7f2b1bcbaab797", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c8b9b3ca196f238029417d5d5e5736615a421e7d", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From 2b43d2955d3ca6a5b9bdb44ab6c3bbd46bd5dd7b Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 10 Nov 2026 19:43:33 +0100 Subject: [PATCH 32/43] chore: Fix oxlint override comments and compute warningCount once --- config/oxlint/lint.mjs | 8 ++++---- oxlint.config.mts | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/config/oxlint/lint.mjs b/config/oxlint/lint.mjs index 76e0299de..8fc7d9612 100644 --- a/config/oxlint/lint.mjs +++ b/config/oxlint/lint.mjs @@ -96,8 +96,7 @@ function lint(targets, fix) { return report.diagnostics.map(toLintMessage); } -function formatStylish(messages, errorCount, showWarnings) { - const warningCount = messages.length - errorCount; +function formatStylish(messages, errorCount, warningCount, showWarnings) { const visible = showWarnings ? messages : messages.filter((message) => message.severity === LINT_SEVERITY.ERROR); const lines = visible.map((message) => { const location = `${path.relative(projectRoot, message.filePath)}:${message.line}:${message.column}`; @@ -128,9 +127,10 @@ if (!lintedFiles.length) { const {messages} = applySeatbelt(lint(targets, values.fix), resolveSeatbeltOptions(projectRoot), lintedFiles); const errorCount = messages.filter((message) => message.severity === LINT_SEVERITY.ERROR).length; +const warningCount = messages.length - errorCount; if (values.format === 'json') { - console.log(JSON.stringify({messages, errorCount, warningCount: messages.length - errorCount})); + console.log(JSON.stringify({messages, errorCount, warningCount})); } else { - console.log(formatStylish(messages, errorCount, values['show-warnings'])); + console.log(formatStylish(messages, errorCount, warningCount, values['show-warnings'])); } process.exitCode = errorCount > 0 ? 1 : 0; diff --git a/oxlint.config.mts b/oxlint.config.mts index aba89dac6..05d4e13ec 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -7,6 +7,7 @@ import {buildOxlintConfig, getPresetRuleEntry} from 'eslint-config-expensify/oxl // The jest preset applies to Onyx's test and mock files only. const JEST_FILES = ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-sequencer.js', 'jest-test-environment.ts']; +// Onyx's tsconfig rejects a standalone top-level await (TS1378), so the preset rule entries are awaited inline below. export default await buildOxlintConfig({ presets: ['browser', 'react', 'typescript', {preset: 'jest', files: JEST_FILES}, 'scripts', 'prettier'], // Onyx keeps oxlint's own exhaustive-deps, as before the switch to the package. @@ -46,8 +47,8 @@ export default await buildOxlintConfig({ ], }, }, - // Node tooling that runs on the current Node, so the old-browser `es/*` rules do not apply. Rule names are - // quoted keys, `.mjs` imports need their extension in Node, and the lint CLI prints its result with console. + // Node tooling that runs on the current Node, so the old-browser `es/*` rules do not apply. `.mjs` imports need + // their extension in Node, and the lint CLI prints its result with console. { files: ['oxlint.config.mts', 'config/oxlint/**/*'], env: { @@ -60,7 +61,8 @@ export default await buildOxlintConfig({ 'no-console': 'off', }, }, - // The presets already leave these off for the .mjs scripts; the config file is TypeScript. + // The presets turn these on only for TypeScript files, and the config file is TypeScript: its rule names are + // quoted keys, and it imports eslint-config-expensify, a devDependency. { files: ['oxlint.config.mts'], rules: { From 1ce94c16d47e222baec9856ad9563ad96db339f6 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 10 Nov 2026 19:46:04 +0100 Subject: [PATCH 33/43] chore: Bump eslint-config-expensify to adaccd2 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c5b801e62..36b878417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c8b9b3ca196f238029417d5d5e5736615a421e7d", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#adaccd2465ae47d3db0c44bf8416d2cd19cea63c", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#c8b9b3ca196f238029417d5d5e5736615a421e7d", - "integrity": "sha512-lPNYKSxM7Bl1dc/loFKnBaeyjLjgs09y+HDvXfnca1hAw1vs7RFl67qAM12mwqaQ7zwZMZoN/bWpEwizXuXT6A==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#adaccd2465ae47d3db0c44bf8416d2cd19cea63c", + "integrity": "sha512-lRg2Qep3zUFPkZSO/5D/XcHBhOER1RwhAmZ/St7yNeybnNXL/TXHTx+W3LyrhNDaCswPZbS38ajxxd7WO9zPbw==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 3b9ab8dc0..a182491c5 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c8b9b3ca196f238029417d5d5e5736615a421e7d", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#adaccd2465ae47d3db0c44bf8416d2cd19cea63c", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From a845f5ef25100b1dbce5965ad74527619ec895eb Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 10 Nov 2026 20:47:39 +0100 Subject: [PATCH 34/43] chore: Bump eslint-config-expensify to 95cf16f --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36b878417..ef3d1174d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#adaccd2465ae47d3db0c44bf8416d2cd19cea63c", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#95cf16f7166d961d942d8b5c7a6b6ca32ece0ef6", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#adaccd2465ae47d3db0c44bf8416d2cd19cea63c", - "integrity": "sha512-lRg2Qep3zUFPkZSO/5D/XcHBhOER1RwhAmZ/St7yNeybnNXL/TXHTx+W3LyrhNDaCswPZbS38ajxxd7WO9zPbw==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#95cf16f7166d961d942d8b5c7a6b6ca32ece0ef6", + "integrity": "sha512-XNV+lgltgr61gqQdxXCHfX6NKpARJGvyHZEEfQ2n6agSFa+ugjRsJj8EXyMbI2wlGHsmZXGxUAh/qbY7ztboGQ==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index a182491c5..57017f185 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#adaccd2465ae47d3db0c44bf8416d2cd19cea63c", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#95cf16f7166d961d942d8b5c7a6b6ca32ece0ef6", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From b1e6a543e549b1d43177c78af1a9a637bf43a74b Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 11 Nov 2026 08:28:29 +0100 Subject: [PATCH 35/43] chore: Bump eslint-config-expensify to 72fcf03 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ef3d1174d..b970e044a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#95cf16f7166d961d942d8b5c7a6b6ca32ece0ef6", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#72fcf03c147a30b48d2bec6af9cffac5a1a7d2c7", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#95cf16f7166d961d942d8b5c7a6b6ca32ece0ef6", - "integrity": "sha512-XNV+lgltgr61gqQdxXCHfX6NKpARJGvyHZEEfQ2n6agSFa+ugjRsJj8EXyMbI2wlGHsmZXGxUAh/qbY7ztboGQ==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#72fcf03c147a30b48d2bec6af9cffac5a1a7d2c7", + "integrity": "sha512-J+X1A4z2IirePKWaV0C5p5lMwCyhJaY2mlkvdBxQfy+IpNt74h3EM9XDh8XAhmbeWXyvmUBbM6Lkg0AV6/rvAw==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 57017f185..e02025600 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#95cf16f7166d961d942d8b5c7a6b6ca32ece0ef6", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#72fcf03c147a30b48d2bec6af9cffac5a1a7d2c7", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From 8481c8b89c1555915b17412ec23ed56efa06c65a Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 12 Nov 2026 09:43:44 +0100 Subject: [PATCH 36/43] docs: Tidy oxlint comments and JSDoc --- .github/workflows/lint.yml | 3 +- config/oxlint/lint.mjs | 38 +++++++++++-- config/oxlint/seatbelt.mjs | 112 ++++++++++++++++++++++++++++++++++--- oxlint.config.mts | 17 +++--- 4 files changed, 146 insertions(+), 24 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63248069e..5154ef445 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -39,8 +39,7 @@ jobs: env: CI: true - # On main, lint rewrites the seatbelt file when merged PRs fixed grandfathered errors. os-botify commits it - # back the same way the publish workflow commits version bumps. + # On main, lint rewrites the seatbelt file when merged PRs fixed grandfathered errors. os-botify commits it back. - name: Check for tightened seatbelt baseline id: seatbelt if: github.event_name == 'push' diff --git a/config/oxlint/lint.mjs b/config/oxlint/lint.mjs index 8fc7d9612..2d8d7ca56 100644 --- a/config/oxlint/lint.mjs +++ b/config/oxlint/lint.mjs @@ -18,11 +18,20 @@ const OXLINT_BIN = path.join(projectRoot, 'node_modules', '.bin', 'oxlint'); const FATAL_EXIT_CODE = 2; const JS_PLUGIN_CRASH_MESSAGE = 'Error running JS plugin'; +/** + * Prints the reason, then up to 2000 characters of the output, and exits the process with the fatal exit code. + * @param {string} reason - What went wrong. + * @param {string} [output] - Output to show after the reason. + */ function fail(reason, output = '') { console.error([reason, output.trim().slice(0, 2000)].filter(Boolean).join('\n')); process.exit(FATAL_EXIT_CODE); } +/** + * Runs the Oxlint binary and returns its stdout, stderr and exit code. Exits the process if the binary cannot start. + * @param {string[]} args - Command line arguments for Oxlint. + */ function runOxlint(args) { const result = spawnSync(OXLINT_BIN, args, {cwd: projectRoot, encoding: 'utf8', maxBuffer: 512 * 1024 * 1024}); if (result.error) { @@ -31,7 +40,10 @@ function runOxlint(args) { return {stdout: result.stdout ?? '', stderr: result.stderr ?? '', exitCode: result.status ?? FATAL_EXIT_CODE}; } -// Oxlint's JSON report leaves out files without findings, and the seatbelt needs them to notice fixed errors. +/** + * Oxlint's JSON report leaves out files without findings, and the seatbelt needs them to notice fixed errors. + * @param {string[]} targets - Paths or globs passed to Oxlint. + */ function listLintedFiles(targets) { return runOxlint(['--debug=files', ...targets]) .stdout.split('\n') @@ -40,8 +52,11 @@ function listLintedFiles(targets) { .map((file) => path.resolve(projectRoot, file)); } -// `typescript(no-explicit-any)` becomes `typescript/no-explicit-any` and `eslint(no-undef)` becomes `no-undef`, -// the names oxlint.config uses, so baseline rows can be matched to config and disable comments by name. +/** + * `typescript(no-explicit-any)` becomes `typescript/no-explicit-any` and `eslint(no-undef)` becomes `no-undef`, + * the names oxlint.config uses, so baseline rows can be matched to config and disable comments by name. + * @param {string} code - Rule code from an Oxlint diagnostic. + */ function toRuleID(code) { const match = /^([\w@/.-]+)\((.+)\)$/.exec(code); if (!match) { @@ -64,6 +79,12 @@ function toLintMessage(diagnostic) { }; } +/** + * Runs Oxlint with type-aware rules on the targets and returns its findings as lint messages. + * Exits the process if Oxlint crashes or its output is unusable. + * @param {string[]} targets - Paths or globs to lint. + * @param {boolean} fix - Whether to apply Oxlint's auto-fixes. + */ function lint(targets, fix) { const {stdout, stderr, exitCode} = runOxlint(['--type-aware', '--format', 'json', ...(fix ? ['--fix'] : []), ...targets]); // Oxlint exits 1 for findings. Anything higher, or no JSON at all, means it crashed or rejected the config. @@ -81,8 +102,8 @@ function lint(targets, fix) { if (!Array.isArray(report?.diagnostics)) { fail('Oxlint JSON output has no `diagnostics` array.', stdout); } - // A JS plugin crash also has no rule, but no location either. It would hide every JS plugin finding in the - // file, so it fails the run instead of passing as a parse error. + // A JS plugin crash has no rule and no location. It hides every JS plugin finding in the file, so fail the run + // instead of reporting a parse error. const crashes = report.diagnostics.filter((diagnostic) => !diagnostic.code && (diagnostic.message.startsWith(JS_PLUGIN_CRASH_MESSAGE) || !diagnostic.labels?.length)); if (crashes.length) { fail( @@ -96,6 +117,13 @@ function lint(targets, fix) { return report.diagnostics.map(toLintMessage); } +/** + * Formats messages as `file:line:column` blocks followed by an error count. Warnings are listed and counted only with showWarnings. + * @param {object[]} messages - Findings after the seatbelt. + * @param {number} errorCount - Number of errors in messages. + * @param {number} warningCount - Number of warnings in messages. + * @param {boolean} showWarnings - Whether to list and count warnings. + */ function formatStylish(messages, errorCount, warningCount, showWarnings) { const visible = showWarnings ? messages : messages.filter((message) => message.severity === LINT_SEVERITY.ERROR); const lines = visible.map((message) => { diff --git a/config/oxlint/seatbelt.mjs b/config/oxlint/seatbelt.mjs index 4de6ed5e5..8a1280d27 100644 --- a/config/oxlint/seatbelt.mjs +++ b/config/oxlint/seatbelt.mjs @@ -4,10 +4,14 @@ // Local runs only read the file, so fixing errors leaves the working tree clean. CI writes it, and the lint job on // main commits the tightened file back as os-botify (.github/workflows/lint.yml). // -// Env: SEATBELT_INCREASE= allows and records an increase (commit the file with your change), -// SEATBELT_READ_ONLY=0 writes locally, SEATBELT_FROZEN=1 fails on any difference from the file, SEATBELT_KEEP= -// keeps rows that reached zero, SEATBELT_DISABLE=1 turns it off, SEATBELT_QUIET=1 hides the grandfathered warnings, -// SEATBELT_VERBOSE=1 logs every decision. +// Env: +// SEATBELT_INCREASE= allow and record an increase (commit the file with your change) +// SEATBELT_READ_ONLY=0 write the file locally +// SEATBELT_FROZEN=1 fail on any difference from the file +// SEATBELT_KEEP= keep rows that reached zero +// SEATBELT_DISABLE=1 turn the seatbelt off +// SEATBELT_QUIET=1 hide the grandfathered warnings +// SEATBELT_VERBOSE=1 log every decision import fs from 'node:fs'; import path from 'node:path'; @@ -21,8 +25,11 @@ const LINT_SEVERITY = {WARNING: 1, ERROR: 2}; const toRelativePath = (baseFile, filename) => (path.isAbsolute(filename) ? path.relative(path.dirname(baseFile), filename) : filename); const toAbsolutePath = (baseFile, filename) => (path.isAbsolute(filename) ? filename : path.resolve(path.dirname(baseFile), filename)); -// Each data line holds tab-separated JSON values. Comments before the first row are the header, comments after the -// last row are trailing, and any other comment travels with the row below it when rows are sorted. +/** + * Each data line holds tab-separated JSON values. Comments before the first row are the header, comments after the + * last row are trailing, and any other comment travels with the row below it when rows are sorted. + * @param {string} text - Contents of the file. + */ function parseTSV(text) { const rows = []; const leading = []; @@ -50,6 +57,13 @@ function parseTSV(text) { return {leadingComments: leading.join('\n'), trailingComments: pending.join('\n'), rows}; } +/** + * Writes the header, then the rows sorted with their comments, then the trailing comments. + * @param {object} parts - The header comments, trailing comments and rows to write. + * @param {string} parts.leadingComments - Header comments. + * @param {string} parts.trailingComments - Comments after the last row. + * @param {object[]} parts.rows - Rows with their cells and comments. + */ function serializeTSV({leadingComments, trailingComments, rows}) { const appendBlock = (output, block) => (block ? output + (block.endsWith('\n') ? block : `${block}\n`) : output); const items = rows.map((row) => ({comments: row.comments, encoded: `${row.cells.map((cell) => JSON.stringify(cell)).join('\t')}\n`})); @@ -63,6 +77,11 @@ function serializeTSV({leadingComments, trailingComments, rows}) { const ruleSetHas = (ruleSet, ruleID) => ruleSet === 'all' || ruleSet.has(ruleID); +/** + * Checks that a parsed row is a file name, a rule ID and a count, and returns it as a baseline line. + * @param {unknown[]} row - Parsed cells of one line. + * @param {number} lineNumber - Position of the row, used in error messages. + */ function asSeatbeltLine(row, lineNumber) { if (row.length !== 3) { throw new Error(`Expected 3 tab-separated JSON values at line ${lineNumber}, instead have ${row.length}`); @@ -74,6 +93,11 @@ function asSeatbeltLine(row, lineNumber) { return {filename, ruleID, maxErrors, comments: ''}; } +/** + * Reads baseline text into a Map from file name to that file's lines, plus the header and trailing comments. + * Throws on a row that is not a file name, a rule ID and a count. + * @param {string} text - Contents of the baseline file. + */ function parseSeatbeltTSV(text) { const parsed = parseTSV(text); const data = new Map(); @@ -87,6 +111,13 @@ function parseSeatbeltTSV(text) { return {data, comments: parsed.leadingComments, trailingComments: parsed.trailingComments}; } +/** + * Turns the baseline Map back into text. A file whose counts were updated (maxErrors) is written from those counts, + * and each row keeps its comments. + * @param {Map} data - Baseline per file name. + * @param {string} comments - Header comments. + * @param {string} [trailingComments] - Comments after the last row. + */ function serializeSeatbeltTSV(data, comments, trailingComments = '') { const rows = []; for (const [filename, fileState] of data) { @@ -101,6 +132,12 @@ function serializeSeatbeltTSV(data, comments, trailingComments = '') { return serializeTSV({leadingComments: comments, trailingComments, rows}); } +/** + * Returns a file's allowed error count per rule, or undefined if the baseline has no rows for it. The Map is built + * from the file's lines on the first call and kept in fileState.maxErrors, where updateMaxErrors also stores new counts. + * @param {Map} data - Baseline per file name. + * @param {string} relativeFilename - File name as written in the baseline. + */ function getMaxErrors(data, relativeFilename) { const fileState = data.get(relativeFilename); if (!fileState) { @@ -112,6 +149,10 @@ function getMaxErrors(data, relativeFilename) { const isCountableError = (message) => message.severity === LINT_SEVERITY.ERROR && !!message.ruleID; +/** + * Counts the errors per rule ID. Warnings and messages without a rule are skipped. + * @param {object[]} messages - Findings of one file. + */ function countRuleIDs(messages) { const counts = new Map(); for (const message of messages) { @@ -124,6 +165,11 @@ function countRuleIDs(messages) { const pluralErrors = (count) => (count === 1 ? 'error' : 'errors'); +/** + * Orders messages by file, then rule (messages without a rule first), then line and column. + * @param {object} a - First message. + * @param {object} b - Second message. + */ function compareMessages(a, b) { if (a.filePath !== b.filePath) { return a.filePath < b.filePath ? -1 : 1; @@ -140,7 +186,10 @@ function compareMessages(a, b) { return a.line - b.line || a.column - b.column; } -// Sorted by file, rule, line and column, so "the first N of M errors" are the same ones on every run. +/** + * Sorted by file, rule, line and column, so "the first N of M errors" are the same ones on every run. + * @param {object[]} messages - Findings to sort. They are copied, not changed. + */ const canonicalizeMessages = (messages) => [...messages].sort(compareMessages); function verboseLog(options, makeMessage) { @@ -158,6 +207,15 @@ If you fixed ${fixed} ${fixed === 1 ? 'error' : 'errors'}, thank you, but you'll Try running npm run lint, then committing ${seatbeltFilename}.`; } +/** + * Applies a file's baseline to its messages: errors up to the allowed count for a rule become warnings, and the rest + * stay errors unless SEATBELT_INCREASE names the rule. With SEATBELT_FROZEN, a count below the baseline is an error + * too. With SEATBELT_QUIET, the warnings are dropped. + * @param {object} options - Seatbelt options. + * @param {Map} data - Baseline per file name. + * @param {string} filename - Absolute path of the file. + * @param {object[]} messages - Findings of that file. + */ function transformMessages(options, data, filename, messages) { const ruleToMaxErrorCount = getMaxErrors(data, toRelativePath(options.seatbeltFile, filename)); const allowIncrease = options.allowIncreaseRules === 'all' || options.allowIncreaseRules.size > 0; @@ -239,6 +297,16 @@ function transformMessages(options, data, filename, messages) { }); } +/** + * Works out a file's new allowed counts from its current error counts. Counts go down when errors were fixed, go up + * only for SEATBELT_INCREASE rules, and rules that reached zero are dropped unless SEATBELT_KEEP names them. The new + * counts are stored in data unless SEATBELT_FROZEN is set. + * @param {object} options - Seatbelt options. + * @param {Map} data - Baseline per file name. + * @param {string} filename - Absolute path of the file. + * @param {Map} ruleToErrorCount - Current error count per rule in that file. + * @returns - The rules that were dropped, and whether the stored counts changed. + */ function updateMaxErrors(options, data, filename, ruleToErrorCount) { const removedRules = new Set(); let changedRules = 0; @@ -279,6 +347,13 @@ function updateMaxErrors(options, data, filename, ruleToErrorCount) { return {removedRules, changed}; } +/** + * Creates one error per rule that still has a baseline row but no longer has any errors, which frozen mode cannot update. + * @param {string} filename - Absolute path of the file. + * @param {string} seatbeltFile - Absolute path of the baseline file. + * @param {Set} removedRules - Rules whose errors are all fixed. + * @param {Map} maxErrorsBefore - Allowed counts per rule before the run. + */ function frozenRemovedRuleMessages(filename, seatbeltFile, removedRules, maxErrorsBefore) { return [...removedRules].map((ruleID) => ({ filePath: filename, @@ -290,6 +365,11 @@ function frozenRemovedRuleMessages(filename, seatbeltFile, removedRules, maxErro })); } +/** + * Writes through a temp file and a rename, so an interrupted run cannot leave a half-written baseline. Writes directly if the rename crosses devices (EXDEV). + * @param {string} seatbeltFile - Absolute path of the baseline file. + * @param {string} tsv - Text to write. + */ function writeTSVAtomically(seatbeltFile, tsv) { const tempPath = `${seatbeltFile}.${process.pid}.${Date.now()}.tmp`; fs.writeFileSync(tempPath, tsv); @@ -312,7 +392,13 @@ const readText = (file) => { } }; -// lintedFilenames must include files without findings, so rows for fully fixed files are removed. +/** + * Applies the baseline to the messages, then writes the updated baseline unless the run is read-only or frozen. + * lintedFilenames must include files without findings, so rows for fully fixed files are removed. + * @param {object[]} messages - Findings from Oxlint. + * @param {object} options - Seatbelt options. + * @param {string[]} lintedFilenames - Absolute paths of every linted file. + */ function applySeatbelt(messages, options, lintedFilenames) { if (options.disable) { const existing = readText(options.seatbeltFile) ?? DEFAULT_FILE_HEADER; @@ -381,6 +467,10 @@ function readBooleanEnvVar(value) { return !['0', 'false', 'no'].includes(value.toLowerCase()); } +/** + * Reads an env var holding rule IDs split by commas or spaces. `all`, `1` and `true` mean every rule, and an unset variable gives undefined. + * @param {string | undefined} value - Value of the env var. + */ function parseRuleSetEnvVar(value) { if (value === undefined) { return undefined; @@ -391,6 +481,12 @@ function parseRuleSetEnvVar(value) { return ['all', '1', 'true'].includes(value.toLowerCase()) ? 'all' : new Set(value.split(/[\s,]+/g).filter(Boolean)); } +/** + * Builds the options from the SEATBELT_* env vars. The file is read-only unless CI is set, SEATBELT_READ_ONLY says + * otherwise, or SEATBELT_INCREASE is set. + * @param {string} projectRoot - Absolute path of the repo root. + * @param {Record} [env] - Environment variables to read. + */ function resolveSeatbeltOptions(projectRoot, env = process.env) { const allowIncreaseRules = parseRuleSetEnvVar(env.SEATBELT_INCREASE) ?? new Set(); const isIncreaseSet = allowIncreaseRules === 'all' || allowIncreaseRules.size > 0; diff --git a/oxlint.config.mts b/oxlint.config.mts index 05d4e13ec..c7351457c 100644 --- a/oxlint.config.mts +++ b/oxlint.config.mts @@ -1,7 +1,6 @@ -// Rules come from eslint-config-expensify's presets, converted to oxlint by buildOxlintConfig. Onyx leaves out the -// expensify preset on purpose. The presets don't match the rules in the old eslint.config.mjs: some rules it turned -// off are on now (their findings are in config/oxlint/oxlint.seatbelt.tsv), and a few it turned on are not set. -// Below are Onyx's own ignores, settings and rule overrides. +// Rules come from eslint-config-expensify's presets via buildOxlintConfig. The expensify preset is not used. Existing +// findings are baselined in config/oxlint/oxlint.seatbelt.tsv. The rest of this file is Onyx's own ignores, settings and +// overrides. import {buildOxlintConfig, getPresetRuleEntry} from 'eslint-config-expensify/oxlint'; // The jest preset applies to Onyx's test and mock files only. @@ -10,7 +9,7 @@ const JEST_FILES = ['tests/**', 'lib/**/__mocks__/**', 'jestSetup.js', 'jest-seq // Onyx's tsconfig rejects a standalone top-level await (TS1378), so the preset rule entries are awaited inline below. export default await buildOxlintConfig({ presets: ['browser', 'react', 'typescript', {preset: 'jest', files: JEST_FILES}, 'scripts', 'prettier'], - // Onyx keeps oxlint's own exhaustive-deps, as before the switch to the package. + // Keep oxlint's built-in exhaustive-deps instead of the hosted copy. nativeRules: ['react-hooks/exhaustive-deps'], ignorePatterns: ['dist/**', 'node_modules/**', '**/*.d.ts', '**/*.config.js', '**/*.config.cjs', 'tests/types/**/*.ts', 'cpp/**', 'bench-results/**', '.github/**'], settings: { @@ -32,11 +31,11 @@ export default await buildOxlintConfig({ { files: ['**/*.ts', '**/*.tsx'], rules: { - // The old ESLint config checked imports in TS files, and oxlint has no rule of its own for it. + // oxlint has no import resolution rule of its own, so the hosted one checks imports in TS files. 'hosted/no-unresolved': 'error', - // The typescript preset's options, plus a `_` prefix for parameters kept on purpose, as the old ESLint - // config allowed. An override replaces the whole setting, so it starts from the preset's. oxlint's - // no-unused-vars needs reportVarsOnlyUsedAsTypes to report what typescript-eslint's does. + // The typescript preset's options, plus a `_` prefix for parameters kept on purpose. An override replaces + // the whole setting, so it starts from the preset's. oxlint's no-unused-vars needs + // reportVarsOnlyUsedAsTypes to report what typescript-eslint's does. 'no-unused-vars': [ 'error', { From c95a8fb61a16d1b3ed30386fc69a86811b9e8193 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 12 Nov 2026 09:44:28 +0100 Subject: [PATCH 37/43] chore: Bump eslint-config-expensify to c909982 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index b970e044a..c59cdf4b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#72fcf03c147a30b48d2bec6af9cffac5a1a7d2c7", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c909982619d7234a4d385401ac32a8dfdbce3e65", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#72fcf03c147a30b48d2bec6af9cffac5a1a7d2c7", - "integrity": "sha512-J+X1A4z2IirePKWaV0C5p5lMwCyhJaY2mlkvdBxQfy+IpNt74h3EM9XDh8XAhmbeWXyvmUBbM6Lkg0AV6/rvAw==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#c909982619d7234a4d385401ac32a8dfdbce3e65", + "integrity": "sha512-fuz0yz5wq4FfJttM/AGsB4zx70tvJo8GBbUzsGqvieurucdUXe1frxSItcC5/CJp4h9MQ6X2gM+zLJggReXAeA==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index e02025600..17cfa569e 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#72fcf03c147a30b48d2bec6af9cffac5a1a7d2c7", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c909982619d7234a4d385401ac32a8dfdbce3e65", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From 01a8846b1d01590ac18a1b44fe3e46080d7080b2 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 12 Nov 2026 11:29:24 +0100 Subject: [PATCH 38/43] chore: Raise the oxlint baseline for tests added on main --- config/oxlint/oxlint.seatbelt.tsv | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/config/oxlint/oxlint.seatbelt.tsv b/config/oxlint/oxlint.seatbelt.tsv index b1c4dab63..d9251b0a4 100644 --- a/config/oxlint/oxlint.seatbelt.tsv +++ b/config/oxlint/oxlint.seatbelt.tsv @@ -90,15 +90,16 @@ "../../tests/unit/onyxMultiMergeWebStorageTest.ts" "typescript/no-unsafe-member-access" 1 "../../tests/unit/onyxMultiMergeWebStorageTest.ts" "typescript/no-unsafe-type-assertion" 1 "../../tests/unit/OnyxSubscriptionManagerTest.ts" "typescript/no-unnecessary-type-assertion" 1 -"../../tests/unit/OnyxSubscriptionManagerTest.ts" "typescript/no-unsafe-type-assertion" 1 +"../../tests/unit/OnyxSubscriptionManagerTest.ts" "typescript/no-unsafe-member-access" 1 +"../../tests/unit/OnyxSubscriptionManagerTest.ts" "typescript/no-unsafe-type-assertion" 4 "../../tests/unit/onyxTest.ts" "hosted/no-extraneous-dependencies" 2 "../../tests/unit/onyxTest.ts" "typescript/no-deprecated" 2 -"../../tests/unit/onyxTest.ts" "typescript/no-unnecessary-type-assertion" 67 -"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-assignment" 15 -"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-call" 6 -"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-member-access" 28 +"../../tests/unit/onyxTest.ts" "typescript/no-unnecessary-type-assertion" 70 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-assignment" 16 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-call" 7 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-member-access" 29 "../../tests/unit/onyxTest.ts" "typescript/no-unsafe-return" 1 -"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-type-assertion" 46 +"../../tests/unit/onyxTest.ts" "typescript/no-unsafe-type-assertion" 47 "../../tests/unit/onyxUtilsTest.ts" "typescript/no-unnecessary-type-assertion" 21 "../../tests/unit/onyxUtilsTest.ts" "typescript/no-unsafe-assignment" 9 "../../tests/unit/onyxUtilsTest.ts" "typescript/no-unsafe-call" 2 @@ -128,7 +129,10 @@ "../../tests/unit/storage/tryOrDegradePerformanceTest.ts" "typescript/no-unsafe-return" 1 "../../tests/unit/storage/tryOrDegradePerformanceTest.ts" "typescript/no-unsafe-type-assertion" 1 "../../tests/unit/useOnyxTest.ts" "typescript/no-unnecessary-type-assertion" 17 -"../../tests/unit/useOnyxTest.ts" "typescript/no-unsafe-type-assertion" 22 +"../../tests/unit/useOnyxTest.ts" "typescript/no-unsafe-assignment" 2 +"../../tests/unit/useOnyxTest.ts" "typescript/no-unsafe-call" 2 +"../../tests/unit/useOnyxTest.ts" "typescript/no-unsafe-member-access" 2 +"../../tests/unit/useOnyxTest.ts" "typescript/no-unsafe-type-assertion" 24 "../../tests/unit/useOnyxTest.ts" "typescript/prefer-nullish-coalescing" 1 "../../tests/unit/utilsTest.ts" "typescript/no-restricted-types" 1 "../../tests/unit/utilsTest.ts" "typescript/no-unnecessary-type-assertion" 2 From d84feefb30536feae97a36eb88c20cd9560b1c4b Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 12 Nov 2026 15:48:10 +0100 Subject: [PATCH 39/43] chore: Bump eslint-config-expensify to 59d8bd1 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ceb330b16..f5ae10f2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c909982619d7234a4d385401ac32a8dfdbce3e65", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#59d8bd166599bec1c2606fe3c2c1b68b1d454efb", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#c909982619d7234a4d385401ac32a8dfdbce3e65", - "integrity": "sha512-fuz0yz5wq4FfJttM/AGsB4zx70tvJo8GBbUzsGqvieurucdUXe1frxSItcC5/CJp4h9MQ6X2gM+zLJggReXAeA==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#59d8bd166599bec1c2606fe3c2c1b68b1d454efb", + "integrity": "sha512-Qe95yUaQIm8Cys5FHe8hl0sSbVIw9NQJWJXY/DPAPHmWiEmiRMZIMWfKCtk0/9E0LedfsrNfg8wivkLFmaPcEA==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index edaa2722e..a93b619e4 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c909982619d7234a4d385401ac32a8dfdbce3e65", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#59d8bd166599bec1c2606fe3c2c1b68b1d454efb", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From d43bd4b21240f4655effb9f8e9fec409085069eb Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 12 Nov 2026 16:41:48 +0100 Subject: [PATCH 40/43] chore: Bump eslint-config-expensify to df0c4ed --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index f5ae10f2f..09ed97c14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#59d8bd166599bec1c2606fe3c2c1b68b1d454efb", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#df0c4ed1147f89dcd90627aba4bd1354e8b2bdbc", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#59d8bd166599bec1c2606fe3c2c1b68b1d454efb", - "integrity": "sha512-Qe95yUaQIm8Cys5FHe8hl0sSbVIw9NQJWJXY/DPAPHmWiEmiRMZIMWfKCtk0/9E0LedfsrNfg8wivkLFmaPcEA==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#df0c4ed1147f89dcd90627aba4bd1354e8b2bdbc", + "integrity": "sha512-4WS97Lkr7+A+jyYigLqf7kv21Ky/6MtASzZWdUjjBcDMiYPMx1A1hQC9MD7UB/NSHnOqOtZE1o6zc1Y3augzjA==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index a93b619e4..7f4ef943a 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#59d8bd166599bec1c2606fe3c2c1b68b1d454efb", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#df0c4ed1147f89dcd90627aba4bd1354e8b2bdbc", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From 728a36285a0803549d92c6b2a66c266184780649 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 12 Nov 2026 17:05:10 +0100 Subject: [PATCH 41/43] chore: Bump eslint-config-expensify to 3517bc0 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09ed97c14..cd196bd74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#df0c4ed1147f89dcd90627aba4bd1354e8b2bdbc", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#3517bc0b32158f4ea22c5fdec9bdae6c7bfb71a0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8506,8 +8506,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#df0c4ed1147f89dcd90627aba4bd1354e8b2bdbc", - "integrity": "sha512-4WS97Lkr7+A+jyYigLqf7kv21Ky/6MtASzZWdUjjBcDMiYPMx1A1hQC9MD7UB/NSHnOqOtZE1o6zc1Y3augzjA==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#3517bc0b32158f4ea22c5fdec9bdae6c7bfb71a0", + "integrity": "sha512-zRIHHNpVnWbMTK5oXsUVIyiscMP3w2YsdwzOUbbfEr55RyiykNgMc72AA4hAG46e5MAjtIzaqbT3n+J1IeJlCA==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 7f4ef943a..a05f84778 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#df0c4ed1147f89dcd90627aba4bd1354e8b2bdbc", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#3517bc0b32158f4ea22c5fdec9bdae6c7bfb71a0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", From 957f0e2a987119cd90fff2488df95b5285bdc7cb Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 13 Nov 2026 13:20:29 +0100 Subject: [PATCH 42/43] chore: Bump eslint-config-expensify to 6a3a784 --- package-lock.json | 11 +++-------- package.json | 3 +-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd196bd74..ce047908a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#3517bc0b32158f4ea22c5fdec9bdae6c7bfb71a0", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#6a3a784e096a1737b7478bacc88f938f88807f37", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -57,7 +57,6 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", - "oxc-parser": "0.156.0", "oxlint": "1.92.0", "oxlint-tsgolint": "7.0.2005", "prettier": "^2.8.8", @@ -8506,8 +8505,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#3517bc0b32158f4ea22c5fdec9bdae6c7bfb71a0", - "integrity": "sha512-zRIHHNpVnWbMTK5oXsUVIyiscMP3w2YsdwzOUbbfEr55RyiykNgMc72AA4hAG46e5MAjtIzaqbT3n+J1IeJlCA==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#6a3a784e096a1737b7478bacc88f938f88807f37", + "integrity": "sha512-aw8iCX1zqdamH1dUhk+pCwgK58RhhHZghDcbqFUtDAYeoL0Xefh6Eg/F4jXDpYI768SppfcfeiOK9y180nFsng==", "dev": true, "license": "ISC", "dependencies": { @@ -8533,7 +8532,6 @@ }, "peerDependencies": { "@oxlint/migrate": ">=1.90.0 <2", - "oxc-parser": ">=0.147.0 <1", "oxlint": ">=1.90.0 <2", "oxlint-tsgolint": ">=7.0.2005 <8" }, @@ -8541,9 +8539,6 @@ "@oxlint/migrate": { "optional": true }, - "oxc-parser": { - "optional": true - }, "oxlint": { "optional": true }, diff --git a/package.json b/package.json index a05f84778..66b064e01 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#3517bc0b32158f4ea22c5fdec9bdae6c7bfb71a0", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#6a3a784e096a1737b7478bacc88f938f88807f37", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -92,7 +92,6 @@ "jest-environment-jsdom": "^29.7.0", "jsdoc-to-markdown": "^7.1.0", "nodemon": "^3.0.3", - "oxc-parser": "0.156.0", "oxlint": "1.92.0", "oxlint-tsgolint": "7.0.2005", "prettier": "^2.8.8", From ddfdceb1935ebc1ae41992ac53f38b8a44efaa15 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 13 Nov 2026 15:05:24 +0100 Subject: [PATCH 43/43] chore: Bump eslint-config-expensify to c36e57c --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index ce047908a..4fd64b6c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#6a3a784e096a1737b7478bacc88f938f88807f37", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c36e57c42863272f60c06b9b2d1173c863df30f0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", @@ -8505,8 +8505,8 @@ }, "node_modules/eslint-config-expensify": { "version": "4.0.20", - "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#6a3a784e096a1737b7478bacc88f938f88807f37", - "integrity": "sha512-aw8iCX1zqdamH1dUhk+pCwgK58RhhHZghDcbqFUtDAYeoL0Xefh6Eg/F4jXDpYI768SppfcfeiOK9y180nFsng==", + "resolved": "git+ssh://git@github.com/callstack-internal/eslint-config-expensify.git#c36e57c42863272f60c06b9b2d1173c863df30f0", + "integrity": "sha512-O5Yg2QyP40yaGcHOLkj/XVembYobTrGFf3H7iorVVnAAEu587A5sQzEeve+/xAc3r5iPYBqCXqitB7ZVrvpqgQ==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 66b064e01..ddd0e76bb 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "better-sqlite3": "^12.11.1", "date-fns": "^4.1.0", "eslint": "^9.39.2", - "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#6a3a784e096a1737b7478bacc88f938f88807f37", + "eslint-config-expensify": "github:callstack-internal/eslint-config-expensify#c36e57c42863272f60c06b9b2d1173c863df30f0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2",