diff --git a/packages/eslint/generators.json b/packages/eslint/generators.json index 44e35ce19bb70..e5660a5340a82 100644 --- a/packages/eslint/generators.json +++ b/packages/eslint/generators.json @@ -28,6 +28,11 @@ "factory": "./src/generators/convert-to-inferred/convert-to-inferred", "schema": "./src/generators/convert-to-inferred/schema.json", "description": "Convert existing ESLint project(s) using `@nx/eslint:lint` executor to use `@nx/eslint/plugin`." + }, + "setup-oxlint-bridge": { + "factory": "./src/generators/setup-oxlint-bridge/setup-oxlint-bridge", + "schema": "./src/generators/setup-oxlint-bridge/schema.json", + "description": "Install eslint-plugin-oxlint and inject the JITI bridge into ESLint flat config for Oxlint coexistence." } } } diff --git a/packages/eslint/src/generators/setup-oxlint-bridge/schema.d.ts b/packages/eslint/src/generators/setup-oxlint-bridge/schema.d.ts new file mode 100644 index 0000000000000..37fcb15b5051a --- /dev/null +++ b/packages/eslint/src/generators/setup-oxlint-bridge/schema.d.ts @@ -0,0 +1,6 @@ +export interface SetupOxlintBridgeSchema { + skipPackageJson?: boolean; + skipFormat?: boolean; + keepExistingVersions?: boolean; + oxlintConfigPath?: string; +} diff --git a/packages/eslint/src/generators/setup-oxlint-bridge/schema.json b/packages/eslint/src/generators/setup-oxlint-bridge/schema.json new file mode 100644 index 0000000000000..77692cd9d540f --- /dev/null +++ b/packages/eslint/src/generators/setup-oxlint-bridge/schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "SetupOxlintBridge", + "title": "Setup Oxlint Bridge", + "description": "Install eslint-plugin-oxlint and inject the JITI bridge into ESLint flat config so that ESLint automatically disables rules already handled by Oxlint.", + "type": "object", + "properties": { + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not add dependencies to `package.json`." + }, + "skipFormat": { + "type": "boolean", + "default": false, + "description": "Skip formatting files." + }, + "keepExistingVersions": { + "type": "boolean", + "default": false, + "description": "Keep existing versions of dependencies already in `package.json`." + }, + "oxlintConfigPath": { + "type": "string", + "default": "./oxlint.config.ts", + "description": "Relative path from the workspace root to the oxlint config file." + } + }, + "required": [] +} diff --git a/packages/eslint/src/generators/setup-oxlint-bridge/setup-oxlint-bridge.spec.ts b/packages/eslint/src/generators/setup-oxlint-bridge/setup-oxlint-bridge.spec.ts new file mode 100644 index 0000000000000..2d149e63a0ab4 --- /dev/null +++ b/packages/eslint/src/generators/setup-oxlint-bridge/setup-oxlint-bridge.spec.ts @@ -0,0 +1,218 @@ +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Tree, readJson } from '@nx/devkit'; +import { + setupOxlintBridgeGenerator, + injectOxlintBridge, +} from './setup-oxlint-bridge'; + +describe('setup-oxlint-bridge', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + describe('generator', () => { + it('should throw if no ESLint config exists', async () => { + await expect(setupOxlintBridgeGenerator(tree, {})).rejects.toThrow( + 'No ESLint config file found' + ); + }); + + it('should throw if ESLint config is not flat config', async () => { + tree.write('.eslintrc.json', JSON.stringify({ root: true, rules: {} })); + await expect(setupOxlintBridgeGenerator(tree, {})).rejects.toThrow( + 'does not appear to be a flat config' + ); + }); + + it('should skip if eslint-plugin-oxlint is already present', async () => { + tree.write( + 'eslint.config.mjs', + `import oxlint from 'eslint-plugin-oxlint';\nexport default [];\n` + ); + const callback = await setupOxlintBridgeGenerator(tree, {}); + // Content should not be modified further + const content = tree.read('eslint.config.mjs', 'utf-8'); + expect(content).toContain('eslint-plugin-oxlint'); + expect(content).not.toContain('buildFromOxlintConfig'); + }); + + it('should add dependencies to package.json', async () => { + tree.write( + 'eslint.config.mjs', + [ + `import nx from '@nx/eslint-plugin';`, + '', + 'export default [', + ' ...nx.configs["flat/base"],', + '];', + '', + ].join('\n') + ); + + await setupOxlintBridgeGenerator(tree, {}); + + const packageJson = readJson(tree, 'package.json'); + expect(packageJson.devDependencies['eslint-plugin-oxlint']).toBeDefined(); + expect(packageJson.devDependencies['jiti']).toBeDefined(); + }); + + it('should inject the bridge into eslint.config.mjs', async () => { + tree.write( + 'eslint.config.mjs', + [ + `import nx from '@nx/eslint-plugin';`, + '', + 'export default [', + ' ...nx.configs["flat/base"],', + '];', + '', + ].join('\n') + ); + + await setupOxlintBridgeGenerator(tree, {}); + + const content = tree.read('eslint.config.mjs', 'utf-8'); + expect(content).toContain(`import oxlint from 'eslint-plugin-oxlint'`); + expect(content).toContain(`import { createJiti } from 'jiti'`); + expect(content).toContain('createJiti(import.meta.url)'); + expect(content).toContain('jiti.import'); + expect(content).toContain("reportUnusedDisableDirectives: 'off'"); + expect(content).toContain('buildFromOxlintConfig(oxlintConfig)'); + }); + + it('should respect custom oxlintConfigPath', async () => { + tree.write( + 'eslint.config.mjs', + [ + `import nx from '@nx/eslint-plugin';`, + '', + 'export default [', + ' ...nx.configs["flat/base"],', + '];', + '', + ].join('\n') + ); + + await setupOxlintBridgeGenerator(tree, { + oxlintConfigPath: './tools/oxlint.config.ts', + }); + + const content = tree.read('eslint.config.mjs', 'utf-8'); + expect(content).toContain('./tools/oxlint.config.ts'); + }); + + it('should not add deps when skipPackageJson is true', async () => { + tree.write( + 'eslint.config.mjs', + `import nx from '@nx/eslint-plugin';\nexport default [];\n` + ); + + await setupOxlintBridgeGenerator(tree, { skipPackageJson: true }); + + const packageJson = readJson(tree, 'package.json'); + expect( + packageJson.devDependencies?.['eslint-plugin-oxlint'] + ).toBeUndefined(); + }); + }); + + describe('injectOxlintBridge', () => { + it('should inject ESM bridge correctly', () => { + const input = [ + `import nx from '@nx/eslint-plugin';`, + '', + 'export default [', + ' ...nx.configs["flat/base"],', + ' ...nx.configs["flat/typescript"],', + '];', + ].join('\n'); + + const result = injectOxlintBridge(input, 'mjs', './oxlint.config.ts'); + + expect(result).toContain(`import oxlint from 'eslint-plugin-oxlint'`); + expect(result).toContain(`import { createJiti } from 'jiti'`); + expect(result).toContain('createJiti(import.meta.url)'); + expect(result).toContain(`await jiti.import('./oxlint.config.ts')`); + expect(result).toContain("reportUnusedDisableDirectives: 'off'"); + expect(result).toContain('...oxlint.buildFromOxlintConfig(oxlintConfig)'); + // buildFromOxlintConfig should be the last thing before ]; + const closingIdx = result.lastIndexOf('];'); + const beforeClosing = result.slice(0, closingIdx); + expect(beforeClosing.trimEnd()).toMatch( + /buildFromOxlintConfig\(oxlintConfig\),?$/ + ); + }); + + it('should inject CJS bridge correctly', () => { + const input = [ + `const nx = require('@nx/eslint-plugin');`, + '', + 'module.exports = [', + ' ...nx.configs["flat/base"],', + '];', + ].join('\n'); + + const result = injectOxlintBridge(input, 'cjs', './oxlint.config.ts'); + + expect(result).toContain(`require('eslint-plugin-oxlint')`); + expect(result).toContain(`require('jiti')`); + expect(result).toContain('createJiti(__filename)'); + expect(result).toContain("reportUnusedDisableDirectives: 'off'"); + expect(result).toContain('...oxlint.buildFromOxlintConfig(oxlintConfig)'); + }); + + it('should add trailing comma to last existing element', () => { + const input = [ + `import nx from '@nx/eslint-plugin';`, + '', + 'export default [', + ' ...nx.configs["flat/base"]', + '];', + ].join('\n'); + + const result = injectOxlintBridge(input, 'mjs', './oxlint.config.ts'); + + // The existing element without trailing comma should get one + expect(result).toContain('flat/base"],'); + }); + + it('should not duplicate comma if already present', () => { + const input = [ + `import nx from '@nx/eslint-plugin';`, + '', + 'export default [', + ' ...nx.configs["flat/base"],', + '];', + ].join('\n'); + + const result = injectOxlintBridge(input, 'mjs', './oxlint.config.ts'); + + // Should not produce double comma + expect(result).not.toContain(',,'); + }); + + it('should preserve the export default structure', () => { + const input = [ + `import nx from '@nx/eslint-plugin';`, + `import globals from 'globals';`, + '', + 'export default [', + ' { ignores: ["**/dist"] },', + ' ...nx.configs["flat/base"],', + ' { languageOptions: { globals: { ...globals.browser } } },', + '];', + ].join('\n'); + + const result = injectOxlintBridge(input, 'mjs', './oxlint.config.ts'); + + expect(result).toContain('export default ['); + expect(result).toContain('{ ignores: ["**/dist"] }'); + expect(result).toContain('flat/base'); + expect(result).toContain('globals.browser'); + expect(result).toContain('buildFromOxlintConfig'); + expect(result).toMatch(/\];/); + }); + }); +}); diff --git a/packages/eslint/src/generators/setup-oxlint-bridge/setup-oxlint-bridge.ts b/packages/eslint/src/generators/setup-oxlint-bridge/setup-oxlint-bridge.ts new file mode 100644 index 0000000000000..514f0f43538ec --- /dev/null +++ b/packages/eslint/src/generators/setup-oxlint-bridge/setup-oxlint-bridge.ts @@ -0,0 +1,204 @@ +import { + addDependenciesToPackageJson, + formatFiles, + GeneratorCallback, + logger, + runTasksInSerial, + Tree, +} from '@nx/devkit'; +import { findEslintFile } from '../utils/eslint-file'; +import { eslintPluginOxlintVersion, jitiVersion } from '../../utils/versions'; +import type { SetupOxlintBridgeSchema } from './schema'; + +/** + * Injects the JITI bridge into an existing ESLint flat config so that + * `eslint-plugin-oxlint` can dynamically read `oxlint.config.ts` and + * disable overlapping ESLint rules via `buildFromOxlintConfig()`. + * + * This is the community-standard pattern used by Analog alpha, documented + * by `eslint-plugin-oxlint`, and recommended for ESLint/Oxlint coexistence + * during gradual migration. + */ +export async function setupOxlintBridgeGenerator( + tree: Tree, + options: SetupOxlintBridgeSchema +): Promise { + const tasks: GeneratorCallback[] = []; + const oxlintConfigPath = options.oxlintConfigPath ?? './oxlint.config.ts'; + + // 1. Find the existing ESLint flat config + const eslintFile = findEslintFile(tree); + if (!eslintFile) { + throw new Error( + 'No ESLint config file found. Run `nx g @nx/eslint:init` first.' + ); + } + + const content = tree.read(eslintFile, 'utf-8'); + const isFlatConfig = + content.includes('export default') || content.includes('module.exports'); + if (!isFlatConfig) { + throw new Error( + `The ESLint config "${eslintFile}" does not appear to be a flat config. ` + + 'Convert to flat config first with `nx g @nx/eslint:convert-to-flat-config`.' + ); + } + + // 2. Check if the bridge is already set up + if (content.includes('eslint-plugin-oxlint')) { + logger.info( + 'eslint-plugin-oxlint is already referenced in the ESLint config. Skipping.' + ); + return () => {}; + } + + // 3. Install dependencies + if (!options.skipPackageJson) { + tasks.push( + addDependenciesToPackageJson( + tree, + {}, + { + 'eslint-plugin-oxlint': eslintPluginOxlintVersion, + jiti: jitiVersion, + }, + undefined, + options.keepExistingVersions + ) + ); + } + + // 4. Inject the bridge into the ESLint config + const format = content.includes('export default') ? 'mjs' : 'cjs'; + const updatedContent = injectOxlintBridge(content, format, oxlintConfigPath); + tree.write(eslintFile, updatedContent); + + // 5. Format + if (!options.skipFormat) { + await formatFiles(tree); + } + + return runTasksInSerial(...tasks); +} + +/** + * Injects the JITI bridge code into an ESLint flat config string. + * + * Adds: + * - `import oxlint from 'eslint-plugin-oxlint'` + * - `import { createJiti } from 'jiti'` + * - Top-level JITI import of oxlint.config.ts + * - `reportUnusedDisableDirectives: 'off'` config block + * - `...oxlint.buildFromOxlintConfig(oxlintConfig)` spread at end + */ +export function injectOxlintBridge( + content: string, + format: 'mjs' | 'cjs', + oxlintConfigPath: string +): string { + if (format === 'mjs') { + return injectOxlintBridgeESM(content, oxlintConfigPath); + } + return injectOxlintBridgeCJS(content, oxlintConfigPath); +} + +function injectOxlintBridgeESM( + content: string, + oxlintConfigPath: string +): string { + // Add imports before the export default + const importBlock = [ + `import oxlint from 'eslint-plugin-oxlint';`, + `import { createJiti } from 'jiti';`, + '', + `const jiti = createJiti(import.meta.url);`, + `const oxlintConfig = /** @type {{ default: import('oxlint').OxlintConfig }} */ (`, + ` await jiti.import('${oxlintConfigPath}')`, + `).default;`, + ].join('\n'); + + // Find the export default position and insert imports before it + const exportIdx = content.indexOf('export default'); + if (exportIdx === -1) { + return content; + } + + const before = content.slice(0, exportIdx).trimEnd(); + const after = content.slice(exportIdx); + + // Build the bridge config blocks to append inside the export array + const bridgeBlocks = [ + ` {`, + ` linterOptions: {`, + ` reportUnusedDisableDirectives: 'off',`, + ` },`, + ` },`, + ` ...oxlint.buildFromOxlintConfig(oxlintConfig)`, + ].join('\n'); + + // Insert bridge blocks at the end of the export array + const updatedAfter = insertBeforeClosingBracket(after, bridgeBlocks); + + return `${before}\n${importBlock}\n\n${updatedAfter}`; +} + +function injectOxlintBridgeCJS( + content: string, + oxlintConfigPath: string +): string { + // CJS: use require() and synchronous JITI + const importBlock = [ + `const oxlint = require('eslint-plugin-oxlint');`, + `const { createJiti } = require('jiti');`, + '', + `const jiti = createJiti(__filename);`, + `const oxlintConfig = jiti.import('${oxlintConfigPath}', { default: true });`, + ].join('\n'); + + const exportsIdx = content.indexOf('module.exports'); + if (exportsIdx === -1) { + return content; + } + + const before = content.slice(0, exportsIdx).trimEnd(); + const after = content.slice(exportsIdx); + + const bridgeBlocks = [ + ` {`, + ` linterOptions: {`, + ` reportUnusedDisableDirectives: 'off',`, + ` },`, + ` },`, + ` ...oxlint.buildFromOxlintConfig(oxlintConfig)`, + ].join('\n'); + + const updatedAfter = insertBeforeClosingBracket(after, bridgeBlocks); + + return `${before}\n${importBlock}\n\n${updatedAfter}`; +} + +/** + * Insert content before the last `];` in the string. This appends + * config blocks to the end of the flat config export array. + */ +function insertBeforeClosingBracket( + content: string, + insertion: string +): string { + // Find the last `];` which closes the export default array + const closingIdx = content.lastIndexOf('];'); + if (closingIdx === -1) { + return content; + } + + const before = content.slice(0, closingIdx).trimEnd(); + const after = content.slice(closingIdx); + + // Ensure trailing comma on the last element + const needsComma = !before.trimEnd().endsWith(','); + const comma = needsComma ? ',' : ''; + + return `${before}${comma}\n${insertion},\n${after}`; +} + +export default setupOxlintBridgeGenerator; diff --git a/packages/eslint/src/utils/versions.ts b/packages/eslint/src/utils/versions.ts index 65188492647b7..325cfaded94b5 100644 --- a/packages/eslint/src/utils/versions.ts +++ b/packages/eslint/src/utils/versions.ts @@ -10,3 +10,7 @@ export const jsoncEslintParserVersion = '^2.1.0'; export const eslint9__typescriptESLintVersion = '^8.40.0'; export const eslint9__eslintVersion = '^9.8.0'; export const eslintCompat = '^1.1.1'; + +// Oxlint bridge (ESLint ↔ Oxlint coexistence) +export const eslintPluginOxlintVersion = '^1.57.0'; +export const jitiVersion = '^2.6.1';