diff --git a/.prettierrc b/.prettierrc index 544138be4..0c1e15fbb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,5 @@ { - "singleQuote": true + "singleQuote": true, + "endOfLine": "lf", + "trailingComma": "es5" } diff --git a/e2e/oxlint-e2e/.eslintrc.json b/e2e/oxlint-e2e/.eslintrc.json new file mode 100644 index 000000000..2564a6e49 --- /dev/null +++ b/e2e/oxlint-e2e/.eslintrc.json @@ -0,0 +1,23 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": {} + } + ] +} diff --git a/e2e/oxlint-e2e/jest.config.ts b/e2e/oxlint-e2e/jest.config.ts new file mode 100644 index 000000000..f75d1c558 --- /dev/null +++ b/e2e/oxlint-e2e/jest.config.ts @@ -0,0 +1,11 @@ +export default { + displayName: 'oxlint-e2e', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/oxlint-e2e', + globalSetup: '../../tools/scripts/start-local-registry.ts', + globalTeardown: '../../tools/scripts/stop-local-registry.ts', +}; diff --git a/e2e/oxlint-e2e/project.json b/e2e/oxlint-e2e/project.json new file mode 100644 index 000000000..15a7c9d74 --- /dev/null +++ b/e2e/oxlint-e2e/project.json @@ -0,0 +1,21 @@ +{ + "name": "oxlint-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "e2e/oxlint-e2e/src", + "implicitDependencies": ["oxlint"], + "targets": { + "e2e": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "e2e/oxlint-e2e/jest.config.ts", + "runInBand": true + }, + "dependsOn": ["^build"] + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/e2e/oxlint-e2e/src/oxlint.spec.ts b/e2e/oxlint-e2e/src/oxlint.spec.ts new file mode 100644 index 000000000..e04f3d595 --- /dev/null +++ b/e2e/oxlint-e2e/src/oxlint.spec.ts @@ -0,0 +1,73 @@ +import { execSync } from 'child_process'; +import { + cleanupTestProject, + createTestProject, + getChildWorkspaceEnv, + getNxVersion, + runCommand, +} from './utils'; + +describe('oxlint plugin', () => { + let projectDirectory: string; + + beforeAll(() => { + const nxVersion = getNxVersion(); + projectDirectory = createTestProject(nxVersion); + // The plugin has been built and published to a local registry in the jest globalSetup + // Install the plugin built with the latest source code into the test repo + execSync(`yarn add -D -W @nx/oxlint@e2e`, { + cwd: projectDirectory, + stdio: 'inherit', + env: getChildWorkspaceEnv(), + }); + + execSync('yarn nx add @nx/oxlint --no-interactive', { + cwd: projectDirectory, + stdio: 'inherit', + env: getChildWorkspaceEnv(), + }); + + execSync( + 'yarn nx g @nx/js:lib lib-a --unitTestRunner=none --bundler=none --linter=none --no-interactive', + { + cwd: projectDirectory, + stdio: 'inherit', + env: getChildWorkspaceEnv(), + } + ); + }); + + afterAll(() => { + cleanupTestProject(projectDirectory); + }); + + it('should be installed', () => { + // npm ls will fail if the package is not installed properly + execSync('yarn list @nx/oxlint', { + cwd: projectDirectory, + stdio: 'inherit', + }); + }); + + it('should register the plugin in nx.json', () => { + const nxJson = runCommand('cat nx.json', projectDirectory); + expect(nxJson).toContain('@nx/oxlint/plugin'); + }); + + it('should infer an oxlint target for the generated library', () => { + const projectJson = runCommand( + 'yarn nx show project lib-a --json', + projectDirectory + ); + expect(projectJson).toContain('"lint"'); + expect(projectJson).toContain('oxlint lib-a'); + }); + + it('should run oxlint successfully', () => { + execSync('yarn nx run lib-a:lint', { + cwd: projectDirectory, + stdio: 'inherit', + env: getChildWorkspaceEnv(), + }); + }); +}); diff --git a/e2e/oxlint-e2e/src/utils.ts b/e2e/oxlint-e2e/src/utils.ts new file mode 100644 index 000000000..8ef17cec4 --- /dev/null +++ b/e2e/oxlint-e2e/src/utils.ts @@ -0,0 +1,107 @@ +import { readJsonFile, workspaceRoot } from '@nx/devkit'; +import { execSync } from 'child_process'; +import { mkdirSync, rmSync } from 'fs'; +import { dirname, join } from 'path'; + +export function getStrippedEnvironmentVariables() { + return Object.fromEntries( + Object.entries(process.env).filter(([key]) => { + if (key.startsWith('NX_E2E_')) { + return true; + } + + const allowedKeys = [ + 'NX_ADD_PLUGINS', + 'NX_ISOLATE_PLUGINS', + 'NX_VERBOSE_LOGGING', + 'NX_NATIVE_LOGGING', + 'NX_USE_LOCAL', + ]; + + if (key.startsWith('NX_') && !allowedKeys.includes(key)) { + return false; + } + + if (key === 'JEST_WORKER_ID') { + return false; + } + + if (key === 'NODE_PATH') { + return false; + } + + return true; + }) + ); +} + +export function getChildWorkspaceEnv() { + return { + CI: 'true', + NX_NO_CLOUD: 'true', + NX_INTERNAL_USE_LEGACY_VERSIONING: 'false', + ...getStrippedEnvironmentVariables(), + }; +} + +/** + * Gets the version of Nx to use for the test project + * @returns The version of Nx to use for the test project + */ +export function getNxVersion() { + const nxVersion = readJsonFile(join(workspaceRoot, 'package.json')) + .devDependencies['nx']; + return nxVersion; +} + +/** + * Creates a test project with create-nx-workspace and installs the plugin + * @returns The directory where the test project was created + */ +export function createTestProject(nxVersion: string) { + const projectName = 'test-project'; + const projectDirectory = join(process.cwd(), 'tmp', projectName); + + // Ensure projectDirectory is empty + rmSync(projectDirectory, { + recursive: true, + force: true, + }); + mkdirSync(dirname(projectDirectory), { + recursive: true, + }); + + execSync( + `npx -y create-nx-workspace@${nxVersion} ${projectName} --preset apps --nxCloud=skip --no-interactive`, + { + cwd: dirname(projectDirectory), + stdio: 'inherit', + env: getChildWorkspaceEnv(), + } + ); + console.log(`Created test project in "${projectDirectory}"`); + + return projectDirectory; +} + +/** + * Cleans up the test project + * @param projectDirectory The directory where the test project was created + */ +export function cleanupTestProject(projectDirectory: string) { + if (projectDirectory && !process.env.PRESERVE_TEST_PROJECT) { + rmSync(projectDirectory, { + recursive: true, + force: true, + }); + } +} + +export function runCommand(command: string, cwd: string): string { + return execSync(command, { + cwd, + stdio: 'pipe', + env: getChildWorkspaceEnv(), + encoding: 'utf-8', + }); +} diff --git a/e2e/oxlint-e2e/tsconfig.json b/e2e/oxlint-e2e/tsconfig.json new file mode 100644 index 000000000..b9c9d9537 --- /dev/null +++ b/e2e/oxlint-e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/e2e/oxlint-e2e/tsconfig.spec.json b/e2e/oxlint-e2e/tsconfig.spec.json new file mode 100644 index 000000000..0d3c604ea --- /dev/null +++ b/e2e/oxlint-e2e/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/oxlint/.eslintrc.json b/packages/oxlint/.eslintrc.json new file mode 100644 index 000000000..9cf84cd14 --- /dev/null +++ b/packages/oxlint/.eslintrc.json @@ -0,0 +1,44 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": ["{projectRoot}/eslint.config.{js,cjs,mjs}"], + "ignoredDependencies": [ + "nx", + "oxlint", + "minimatch", + "@nx/devkit", + "@nx/eslint-plugin" + ] + } + ] + } + }, + { + "files": ["./package.json", "./generators.json", "./executors.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/nx-plugin-checks": "error" + } + } + ] +} diff --git a/packages/oxlint/README.md b/packages/oxlint/README.md new file mode 100644 index 000000000..413d05ca3 --- /dev/null +++ b/packages/oxlint/README.md @@ -0,0 +1,16 @@ +# Nx Oxlint Plugin + +The Nx Oxlint plugin integrates [Oxlint](https://oxc.rs/docs/guide/usage/linter/) with Nx. + +It provides: + +- Inferred Oxlint tasks via `@nx/oxlint/plugin` +- A compatibility executor (`@nx/oxlint:lint`) for explicit targets +- Generators for setup and hybrid migration +- Experimental module-boundary enforcement bridge for Oxlint JS plugins + +## Installation + +```bash +nx add @nx/oxlint +``` diff --git a/packages/oxlint/executors.json b/packages/oxlint/executors.json new file mode 100644 index 000000000..7dce59b11 --- /dev/null +++ b/packages/oxlint/executors.json @@ -0,0 +1,9 @@ +{ + "executors": { + "lint": { + "implementation": "./src/executors/lint/lint.impl", + "schema": "./src/executors/lint/schema.json", + "description": "Run Oxlint on a project." + } + } +} diff --git a/packages/oxlint/generators.json b/packages/oxlint/generators.json new file mode 100644 index 000000000..251afb32c --- /dev/null +++ b/packages/oxlint/generators.json @@ -0,0 +1,22 @@ +{ + "name": "Nx Oxlint", + "version": "0.1", + "generators": { + "init": { + "factory": "./src/generators/init/init#initGeneratorInternal", + "schema": "./src/generators/init/schema.json", + "description": "Set up the Oxlint plugin.", + "hidden": true + }, + "lint-project": { + "factory": "./src/generators/lint-project/lint-project#lintProjectGeneratorInternal", + "schema": "./src/generators/lint-project/schema.json", + "description": "Add Oxlint to an existing project." + }, + "convert-from-eslint": { + "factory": "./src/generators/convert-from-eslint/convert-from-eslint#convertFromEslintGenerator", + "schema": "./src/generators/convert-from-eslint/schema.json", + "description": "Register @nx/oxlint and add optional hybrid Oxlint targets without removing ESLint." + } + } +} diff --git a/packages/oxlint/index.ts b/packages/oxlint/index.ts new file mode 100644 index 000000000..9c07fb3b1 --- /dev/null +++ b/packages/oxlint/index.ts @@ -0,0 +1,7 @@ +export { + OxlintExecutorSchema, + oxlintExecutor, +} from './src/executors/lint/lint.impl'; +export { convertFromEslintGenerator } from './src/generators/convert-from-eslint/convert-from-eslint'; +export { initGenerator } from './src/generators/init/init'; +export { lintProjectGenerator } from './src/generators/lint-project/lint-project'; diff --git a/packages/oxlint/jest.config.ts b/packages/oxlint/jest.config.ts new file mode 100644 index 000000000..7403c3f6b --- /dev/null +++ b/packages/oxlint/jest.config.ts @@ -0,0 +1,10 @@ +export default { + displayName: 'oxlint', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/oxlint', +}; diff --git a/packages/oxlint/package.json b/packages/oxlint/package.json new file mode 100644 index 000000000..78ad425c6 --- /dev/null +++ b/packages/oxlint/package.json @@ -0,0 +1,57 @@ +{ + "name": "@nx/oxlint", + "version": "0.0.1", + "private": false, + "description": "The Oxlint plugin for Nx contains executors, generators and utilities used for linting JavaScript/TypeScript projects within an Nx workspace.", + "repository": { + "type": "git", + "url": "https://github.com/nrwl/nx-labs.git", + "directory": "packages/oxlint" + }, + "keywords": [ + "Monorepo", + "Web", + "Lint", + "Oxlint", + "CLI", + "Testing" + ], + "main": "./index.js", + "typings": "./index.d.ts", + "types": "./index.d.ts", + "author": "Victor Savkin", + "license": "MIT", + "bugs": { + "url": "https://github.com/nrwl/nx-labs/issues" + }, + "homepage": "https://nx.dev", + "generators": "./generators.json", + "executors": "./executors.json", + "dependencies": { + "@nx/devkit": "~22.5.4", + "@nx/eslint-plugin": "~22.5.4", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "nx": ">=22.0.0 <23.0.0", + "oxlint": ">=1.0.0" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./package.json": "./package.json", + "./plugin": { + "types": "./plugin.d.ts", + "default": "./plugin.js" + }, + "./boundaries-plugin": { + "types": "./src/boundaries-plugin/index.d.ts", + "default": "./src/boundaries-plugin/index.js" + } + } +} diff --git a/packages/oxlint/plugin.ts b/packages/oxlint/plugin.ts new file mode 100644 index 000000000..d48f750e8 --- /dev/null +++ b/packages/oxlint/plugin.ts @@ -0,0 +1,5 @@ +export { + OxlintPluginOptions, + createNodes, + createNodesV2, +} from './src/plugins/plugin'; diff --git a/packages/oxlint/project.json b/packages/oxlint/project.json new file mode 100644 index 000000000..db04ba962 --- /dev/null +++ b/packages/oxlint/project.json @@ -0,0 +1,64 @@ +{ + "name": "oxlint", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/oxlint", + "projectType": "library", + "release": { + "version": { + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk", + "preserveLocalDependencyProtocols": false, + "manifestRootsToUpdate": ["dist/{projectRoot}"] + } + }, + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/oxlint", + "main": "packages/oxlint/index.ts", + "tsConfig": "packages/oxlint/tsconfig.lib.json", + "assets": [ + "packages/oxlint/*.md", + { + "input": "./packages/oxlint/src", + "glob": "**/!(*.ts)", + "output": "./src" + }, + { + "input": "./packages/oxlint/src", + "glob": "**/*.d.ts", + "output": "./src" + }, + { + "input": "./packages/oxlint", + "glob": "*.json", + "ignore": ["tsconfig*.json", "project.json", ".eslintrc.json"], + "output": "." + }, + { + "input": "", + "glob": "LICENSE", + "output": "." + } + ] + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/oxlint/jest.config.ts" + } + } + } +} diff --git a/packages/oxlint/src/boundaries-plugin/index.ts b/packages/oxlint/src/boundaries-plugin/index.ts new file mode 100644 index 000000000..ee7f1ca44 --- /dev/null +++ b/packages/oxlint/src/boundaries-plugin/index.ts @@ -0,0 +1,12 @@ +import enforceModuleBoundaries, { + RULE_NAME, +} from '@nx/eslint-plugin/src/rules/enforce-module-boundaries'; + +// Experimental bridge: reuse Nx's existing project-graph-aware rule implementation. +const nxOxlintBoundariesPlugin: { rules: Record } = { + rules: { + [RULE_NAME]: enforceModuleBoundaries as unknown, + }, +}; + +export = nxOxlintBoundariesPlugin; diff --git a/packages/oxlint/src/executors/lint/lint.impl.spec.ts b/packages/oxlint/src/executors/lint/lint.impl.spec.ts new file mode 100644 index 000000000..2a3823b67 --- /dev/null +++ b/packages/oxlint/src/executors/lint/lint.impl.spec.ts @@ -0,0 +1,78 @@ +import { ExecutorContext } from '@nx/devkit'; +import * as childProcess from 'node:child_process'; +import { oxlintExecutor } from './lint.impl'; + +jest.mock('node:child_process', () => ({ + ...jest.requireActual('node:child_process'), + spawnSync: jest.fn(), +})); + +describe('@nx/oxlint:lint executor', () => { + const spawnSyncMock = childProcess.spawnSync as jest.Mock; + const mockContext: ExecutorContext = { + root: '/root', + cwd: '/root', + projectName: 'lib-a', + targetName: 'oxlint', + configurationName: undefined, + isVerbose: false, + projectsConfigurations: { + version: 2, + projects: { + 'lib-a': { + root: 'libs/lib-a', + targets: {}, + }, + }, + }, + } as unknown as ExecutorContext; + + beforeEach(() => { + spawnSyncMock.mockReset(); + }); + + it('returns success when process exits with 0', async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + const result = await oxlintExecutor( + { + lintFilePatterns: ['{projectRoot}'], + }, + mockContext + ); + + expect(result).toEqual({ success: true }); + expect(spawnSyncMock).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['oxlint', 'libs/lib-a']), + expect.objectContaining({ + cwd: '/root', + }) + ); + }); + + it('returns failure when process exits non-zero', async () => { + spawnSyncMock.mockReturnValue({ status: 1 }); + const result = await oxlintExecutor( + { + lintFilePatterns: ['{projectRoot}'], + quiet: true, + maxWarnings: 0, + }, + mockContext + ); + + expect(result).toEqual({ success: false }); + }); + + it('uses the project root when lintFilePatterns is omitted', async () => { + spawnSyncMock.mockReturnValue({ status: 0 }); + + await oxlintExecutor({ lintFilePatterns: [] }, mockContext); + + expect(spawnSyncMock).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['oxlint', 'libs/lib-a']), + expect.any(Object) + ); + }); +}); diff --git a/packages/oxlint/src/executors/lint/lint.impl.ts b/packages/oxlint/src/executors/lint/lint.impl.ts new file mode 100644 index 000000000..1ddbcb74c --- /dev/null +++ b/packages/oxlint/src/executors/lint/lint.impl.ts @@ -0,0 +1,113 @@ +import { ExecutorContext, getPackageManagerCommand } from '@nx/devkit'; +import { spawnSync } from 'node:child_process'; +import { interpolate } from 'nx/src/tasks-runner/utils'; + +export interface OxlintExecutorSchema { + lintFilePatterns: string[]; + config?: string; + fix?: boolean; + fixSuggestions?: boolean; + fixDangerously?: boolean; + quiet?: boolean; + maxWarnings?: number; + format?: string; + denyWarnings?: boolean; + silent?: boolean; + tsconfig?: string; + experimentalNestedConfig?: boolean; +} + +export async function oxlintExecutor( + options: OxlintExecutorSchema, + context: ExecutorContext +): Promise<{ success: boolean }> { + const projectName = context.projectName; + if (!projectName) { + throw new Error('Executor context is missing projectName.'); + } + + const projectRoot = + context.projectsConfigurations?.projects?.[projectName]?.root ?? '.'; + + const pmc = getPackageManagerCommand(); + const execParts = pmc.exec.split(' '); + const args = createArgs(options, projectRoot, projectName); + const result = spawnSync( + execParts[0], + [...execParts.slice(1), 'oxlint', ...args], + { + cwd: context.root, + stdio: 'inherit', + env: process.env, + } + ); + + return { success: (result.status ?? 1) === 0 }; +} + +function createArgs( + options: OxlintExecutorSchema, + projectRoot: string, + projectName: string +): string[] { + const args: string[] = []; + + if (options.config) { + args.push('--config', options.config); + } + if (options.fix) { + args.push('--fix'); + } + if (options.fixSuggestions) { + args.push('--fix-suggestions'); + } + if (options.fixDangerously) { + args.push('--fix-dangerously'); + } + if (options.quiet) { + args.push('--quiet'); + } + if (typeof options.maxWarnings === 'number') { + args.push(`--max-warnings=${options.maxWarnings}`); + } + if (options.format) { + args.push('--format', options.format); + } + if (options.denyWarnings) { + args.push('--deny-warnings'); + } + if (options.silent) { + args.push('--silent'); + } + if (options.tsconfig) { + args.push('--tsconfig', options.tsconfig); + } + if (options.experimentalNestedConfig) { + args.push('--experimental-nested-config'); + } + + const lintFilePatterns = options.lintFilePatterns?.length + ? options.lintFilePatterns + : ['{projectRoot}']; + + const normalizedPatterns = lintFilePatterns.map((pattern) => { + const interpolated = interpolate(pattern, { + workspaceRoot: '', + projectRoot, + projectName, + }); + const normalized = interpolated.replace(/^\.\//, ''); + if (normalized === projectRoot) { + return projectRoot === '.' ? '.' : projectRoot; + } + if (projectRoot !== '.' && normalized.startsWith(`${projectRoot}/`)) { + return normalized; + } + return normalized; + }); + + args.push(...normalizedPatterns); + return args; +} + +export default oxlintExecutor; diff --git a/packages/oxlint/src/executors/lint/schema.d.ts b/packages/oxlint/src/executors/lint/schema.d.ts new file mode 100644 index 000000000..27ce1df83 --- /dev/null +++ b/packages/oxlint/src/executors/lint/schema.d.ts @@ -0,0 +1,14 @@ +export interface OxlintExecutorSchema { + lintFilePatterns: string[]; + config?: string; + fix?: boolean; + fixSuggestions?: boolean; + fixDangerously?: boolean; + quiet?: boolean; + maxWarnings?: number; + format?: string; + denyWarnings?: boolean; + silent?: boolean; + tsconfig?: string; + experimentalNestedConfig?: boolean; +} diff --git a/packages/oxlint/src/executors/lint/schema.json b/packages/oxlint/src/executors/lint/schema.json new file mode 100644 index 000000000..e86d8c1d1 --- /dev/null +++ b/packages/oxlint/src/executors/lint/schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/schema", + "version": 2, + "title": "Oxlint executor", + "description": "Run Oxlint for a project.", + "type": "object", + "properties": { + "lintFilePatterns": { + "type": "array", + "description": "List of files/globs to lint.", + "items": { + "type": "string" + }, + "default": ["{projectRoot}"] + }, + "config": { + "type": "string", + "description": "Path to an Oxlint config file." + }, + "fix": { + "type": "boolean", + "description": "Automatically fix fixable issues." + }, + "fixSuggestions": { + "type": "boolean", + "description": "Apply suggested fixes in addition to regular fixes." + }, + "fixDangerously": { + "type": "boolean", + "description": "Apply dangerous fixes." + }, + "quiet": { + "type": "boolean", + "description": "Only show errors." + }, + "maxWarnings": { + "type": "number", + "description": "Maximum warnings allowed before failing." + }, + "format": { + "type": "string", + "description": "Output formatter." + }, + "denyWarnings": { + "type": "boolean", + "description": "Treat warnings as failures." + }, + "silent": { + "type": "boolean", + "description": "Silence all diagnostics output." + }, + "tsconfig": { + "type": "string", + "description": "Path to tsconfig used by type-aware rules." + }, + "experimentalNestedConfig": { + "type": "boolean", + "description": "Enable experimental nested config behavior." + } + }, + "required": ["lintFilePatterns"] +} diff --git a/packages/oxlint/src/generators/convert-from-eslint/convert-from-eslint.spec.ts b/packages/oxlint/src/generators/convert-from-eslint/convert-from-eslint.spec.ts new file mode 100644 index 000000000..14d4ddb8a --- /dev/null +++ b/packages/oxlint/src/generators/convert-from-eslint/convert-from-eslint.spec.ts @@ -0,0 +1,36 @@ +import { addProjectConfiguration, readProjectConfiguration } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { convertFromEslintGenerator } from './convert-from-eslint'; + +describe('convertFromEslintGenerator', () => { + it('adds oxlint target from eslint target', async () => { + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'lib-a', { + root: 'libs/lib-a', + sourceRoot: 'libs/lib-a/src', + projectType: 'library', + targets: { + lint: { + executor: '@nx/eslint:lint', + options: { + lintFilePatterns: ['{projectRoot}'], + }, + }, + }, + }); + + await convertFromEslintGenerator(tree, { + skipPackageJson: true, + skipFormat: true, + addExplicitTargets: true, + }); + + const updated = readProjectConfiguration(tree, 'lib-a'); + expect(updated.targets.oxlint).toEqual({ + executor: '@nx/oxlint:lint', + options: { + lintFilePatterns: ['{projectRoot}'], + }, + }); + }); +}); diff --git a/packages/oxlint/src/generators/convert-from-eslint/convert-from-eslint.ts b/packages/oxlint/src/generators/convert-from-eslint/convert-from-eslint.ts new file mode 100644 index 000000000..d05299db3 --- /dev/null +++ b/packages/oxlint/src/generators/convert-from-eslint/convert-from-eslint.ts @@ -0,0 +1,91 @@ +import { + formatFiles, + getProjects, + ProjectConfiguration, + Tree, + updateProjectConfiguration, +} from '@nx/devkit'; +import { initGenerator } from '../init/init'; + +export interface ConvertFromEslintSchema { + project?: string; + targetName?: string; + addExplicitTargets?: boolean; + skipFormat?: boolean; + skipPackageJson?: boolean; + keepExistingVersions?: boolean; +} + +export async function convertFromEslintGenerator( + tree: Tree, + options: ConvertFromEslintSchema +) { + options.targetName ??= 'oxlint'; + options.addExplicitTargets ??= true; + + await initGenerator(tree, { + addPlugin: true, + skipFormat: true, + skipPackageJson: options.skipPackageJson, + keepExistingVersions: options.keepExistingVersions, + }); + + if (options.addExplicitTargets) { + const projects = getProjects(tree); + for (const [projectName, projectConfig] of projects) { + if (options.project && options.project !== projectName) { + continue; + } + maybeAddOxlintTarget( + tree, + projectName, + projectConfig, + options.targetName + ); + } + } + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +function maybeAddOxlintTarget( + tree: Tree, + projectName: string, + projectConfig: ProjectConfiguration, + targetName: string +) { + if (projectConfig.targets?.[targetName]) { + return; + } + + const eslintTarget = + projectConfig.targets?.lint?.executor === '@nx/eslint:lint' + ? projectConfig.targets.lint + : Object.values(projectConfig.targets ?? {}).find( + (target) => + target.executor === '@nx/eslint:lint' || + target.executor === '@nrwl/linter:eslint' + ); + + if (!eslintTarget) { + return; + } + + const lintFilePatterns = eslintTarget.options?.lintFilePatterns ?? [ + '{projectRoot}', + ]; + + projectConfig.targets ??= {}; + projectConfig.targets[targetName] = { + executor: '@nx/oxlint:lint', + options: { + lintFilePatterns, + }, + }; + + updateProjectConfiguration(tree, projectName, projectConfig); +} + +export default convertFromEslintGenerator; diff --git a/packages/oxlint/src/generators/convert-from-eslint/schema.json b/packages/oxlint/src/generators/convert-from-eslint/schema.json new file mode 100644 index 000000000..16d9eb55e --- /dev/null +++ b/packages/oxlint/src/generators/convert-from-eslint/schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "ConvertFromEslintGenerator", + "title": "Convert from ESLint (hybrid-safe)", + "description": "Register @nx/oxlint and add optional hybrid Oxlint targets without removing ESLint.", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Optional project name to convert." + }, + "targetName": { + "type": "string", + "description": "Name for added Oxlint target in hybrid mode.", + "default": "oxlint" + }, + "addExplicitTargets": { + "type": "boolean", + "description": "Add explicit @nx/oxlint:lint targets for projects with ESLint targets.", + "default": true + }, + "skipFormat": { + "type": "boolean", + "default": false + }, + "skipPackageJson": { + "type": "boolean", + "default": false + }, + "keepExistingVersions": { + "type": "boolean", + "default": false + } + } +} diff --git a/packages/oxlint/src/generators/init/init.spec.ts b/packages/oxlint/src/generators/init/init.spec.ts new file mode 100644 index 000000000..8a71a28a6 --- /dev/null +++ b/packages/oxlint/src/generators/init/init.spec.ts @@ -0,0 +1,43 @@ +import { readNxJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { initGeneratorInternal } from './init'; + +describe('initGeneratorInternal', () => { + it('adds root oxlint config', async () => { + const tree = createTreeWithEmptyWorkspace(); + + await initGeneratorInternal(tree, { + addPlugin: false, + skipPackageJson: true, + skipFormat: true, + }); + + expect(tree.exists('.oxlintrc.json')).toBe(true); + }); + + it('sets targetDefaults when plugin is disabled', async () => { + const tree = createTreeWithEmptyWorkspace(); + + await initGeneratorInternal(tree, { + addPlugin: false, + skipPackageJson: true, + skipFormat: true, + }); + + const nxJson = readNxJson(tree); + expect(nxJson.targetDefaults['@nx/oxlint:lint']).toBeDefined(); + }); + + it('does not create .oxlintrc.json when oxlint.config.ts already exists', async () => { + const tree = createTreeWithEmptyWorkspace(); + tree.write('oxlint.config.ts', 'export default { rules: {} };'); + + await initGeneratorInternal(tree, { + addPlugin: false, + skipPackageJson: true, + skipFormat: true, + }); + + expect(tree.exists('.oxlintrc.json')).toBe(false); + }); +}); diff --git a/packages/oxlint/src/generators/init/init.ts b/packages/oxlint/src/generators/init/init.ts new file mode 100644 index 000000000..dc6e05482 --- /dev/null +++ b/packages/oxlint/src/generators/init/init.ts @@ -0,0 +1,107 @@ +import { + addDependenciesToPackageJson, + createProjectGraphAsync, + formatFiles, + GeneratorCallback, + readNxJson, + runTasksInSerial, + Tree, + updateNxJson, + writeJson, +} from '@nx/devkit'; +import { addPlugin } from '@nx/devkit/src/utils/add-plugin'; +import { createNodesV2 } from '../../plugins/plugin'; +import { OXLINT_CONFIG_FILENAMES } from '../../utils/config-file'; +import { nxVersion, oxlintVersion } from '../../utils/versions'; + +export interface InitGeneratorSchema { + skipPackageJson?: boolean; + keepExistingVersions?: boolean; + updatePackageScripts?: boolean; + skipFormat?: boolean; + addPlugin?: boolean; +} + +export async function initGeneratorInternal( + tree: Tree, + options: InitGeneratorSchema +) { + const tasks: GeneratorCallback[] = []; + + const nxJson = readNxJson(tree); + const addPluginDefault = + process.env.NX_ADD_PLUGINS !== 'false' && + nxJson.useInferencePlugins !== false; + + options.addPlugin ??= addPluginDefault; + + if (options.addPlugin) { + await addPlugin( + tree, + await createProjectGraphAsync(), + '@nx/oxlint/plugin', + createNodesV2, + { + targetName: ['lint', 'oxlint', 'oxlint:lint', 'oxlint-lint'], + }, + options.updatePackageScripts + ); + } else { + addTargetDefaults(tree); + } + + ensureRootConfig(tree); + + if (!options.skipPackageJson) { + tasks.push( + addDependenciesToPackageJson( + tree, + {}, + { + '@nx/oxlint': nxVersion, + oxlint: oxlintVersion, + }, + undefined, + options.keepExistingVersions + ) + ); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return runTasksInSerial(...tasks); +} + +export function initGenerator(tree: Tree, options: InitGeneratorSchema) { + return initGeneratorInternal(tree, { addPlugin: false, ...options }); +} + +function addTargetDefaults(tree: Tree) { + const nxJson = readNxJson(tree); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['@nx/oxlint:lint'] ??= {}; + nxJson.targetDefaults['@nx/oxlint:lint'].cache ??= true; + nxJson.targetDefaults['@nx/oxlint:lint'].inputs ??= [ + 'default', + '{workspaceRoot}/.oxlintrc.json', + '{workspaceRoot}/.oxlintrc.jsonc', + '{workspaceRoot}/oxlint.config.ts', + ]; + + updateNxJson(tree, nxJson); +} + +function ensureRootConfig(tree: Tree) { + if (OXLINT_CONFIG_FILENAMES.some((file) => tree.exists(file))) { + return; + } + + writeJson(tree, '.oxlintrc.json', { + $schema: './node_modules/oxlint/configuration_schema.json', + rules: {}, + }); +} + +export default initGenerator; diff --git a/packages/oxlint/src/generators/init/schema.json b/packages/oxlint/src/generators/init/schema.json new file mode 100644 index 000000000..0419745d4 --- /dev/null +++ b/packages/oxlint/src/generators/init/schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "InitGenerator", + "title": "Initialize Oxlint", + "description": "Set up @nx/oxlint in the workspace.", + "type": "object", + "properties": { + "skipPackageJson": { + "type": "boolean", + "description": "Do not add dependencies to package.json", + "default": false + }, + "keepExistingVersions": { + "type": "boolean", + "description": "Keep existing dependency versions when possible.", + "default": false + }, + "updatePackageScripts": { + "type": "boolean", + "description": "Update package.json scripts to use inferred targets.", + "default": true + }, + "skipFormat": { + "type": "boolean", + "default": false + }, + "addPlugin": { + "type": "boolean", + "description": "Add @nx/oxlint/plugin to nx.json." + } + } +} diff --git a/packages/oxlint/src/generators/lint-project/lint-project.spec.ts b/packages/oxlint/src/generators/lint-project/lint-project.spec.ts new file mode 100644 index 000000000..2cbe943d6 --- /dev/null +++ b/packages/oxlint/src/generators/lint-project/lint-project.spec.ts @@ -0,0 +1,25 @@ +import { addProjectConfiguration, readProjectConfiguration } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { lintProjectGeneratorInternal } from './lint-project'; + +describe('lintProjectGeneratorInternal', () => { + it('adds explicit target when plugin is disabled', async () => { + const tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'lib-a', { + root: 'libs/lib-a', + sourceRoot: 'libs/lib-a/src', + projectType: 'library', + targets: {}, + }); + + await lintProjectGeneratorInternal(tree, { + project: 'lib-a', + addPlugin: false, + skipPackageJson: true, + skipFormat: true, + }); + + const project = readProjectConfiguration(tree, 'lib-a'); + expect(project.targets.oxlint).toBeDefined(); + }); +}); diff --git a/packages/oxlint/src/generators/lint-project/lint-project.ts b/packages/oxlint/src/generators/lint-project/lint-project.ts new file mode 100644 index 000000000..1cdcb8c01 --- /dev/null +++ b/packages/oxlint/src/generators/lint-project/lint-project.ts @@ -0,0 +1,71 @@ +import { + formatFiles, + GeneratorCallback, + readNxJson, + readProjectConfiguration, + runTasksInSerial, + Tree, + updateProjectConfiguration, +} from '@nx/devkit'; +import { hasOxlintPlugin } from '../../utils/plugin'; +import { initGenerator } from '../init/init'; + +export interface LintProjectGeneratorSchema { + project: string; + skipFormat?: boolean; + skipPackageJson?: boolean; + keepExistingVersions?: boolean; + addPlugin?: boolean; + addExplicitTargets?: boolean; +} + +export function lintProjectGenerator( + tree: Tree, + options: LintProjectGeneratorSchema +) { + return lintProjectGeneratorInternal(tree, { addPlugin: false, ...options }); +} + +export async function lintProjectGeneratorInternal( + tree: Tree, + options: LintProjectGeneratorSchema +) { + const nxJson = readNxJson(tree); + const addPluginDefault = + process.env.NX_ADD_PLUGINS !== 'false' && + nxJson.useInferencePlugins !== false; + options.addPlugin ??= addPluginDefault; + + const tasks: GeneratorCallback[] = []; + tasks.push( + await initGenerator(tree, { + skipFormat: true, + skipPackageJson: options.skipPackageJson, + keepExistingVersions: options.keepExistingVersions, + addPlugin: options.addPlugin, + }) + ); + + const projectConfig = readProjectConfiguration(tree, options.project); + + const hasPlugin = hasOxlintPlugin(tree); + if (!hasPlugin || options.addExplicitTargets) { + projectConfig.targets ??= {}; + projectConfig.targets['oxlint'] = { + executor: '@nx/oxlint:lint', + options: { + lintFilePatterns: ['{projectRoot}'], + }, + }; + } + + updateProjectConfiguration(tree, options.project, projectConfig); + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return runTasksInSerial(...tasks); +} + +export default lintProjectGenerator; diff --git a/packages/oxlint/src/generators/lint-project/schema.json b/packages/oxlint/src/generators/lint-project/schema.json new file mode 100644 index 000000000..e8cd2900d --- /dev/null +++ b/packages/oxlint/src/generators/lint-project/schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "LintProjectGenerator", + "title": "Add Oxlint to project", + "description": "Add Oxlint to an existing project.", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Project name", + "$default": { + "$source": "projectName" + }, + "x-prompt": "Which project should use Oxlint?" + }, + "skipFormat": { + "type": "boolean", + "default": false + }, + "skipPackageJson": { + "type": "boolean", + "default": false + }, + "keepExistingVersions": { + "type": "boolean", + "default": false + }, + "addPlugin": { + "type": "boolean", + "description": "Register @nx/oxlint/plugin when possible." + }, + "addExplicitTargets": { + "type": "boolean", + "description": "Always add explicit @nx/oxlint:lint target instead of relying on inferred tasks.", + "default": false + } + }, + "required": ["project"] +} diff --git a/packages/oxlint/src/plugins/plugin.spec.ts b/packages/oxlint/src/plugins/plugin.spec.ts new file mode 100644 index 000000000..bdf3168e4 --- /dev/null +++ b/packages/oxlint/src/plugins/plugin.spec.ts @@ -0,0 +1,136 @@ +import { CreateNodesContextV2 } from '@nx/devkit'; +import { minimatch } from 'minimatch'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createNodesV2 } from './plugin'; + +describe('@nx/oxlint/plugin', () => { + let context: CreateNodesContextV2; + let workspaceRoot: string; + let configFiles: string[] = []; + let nxCacheProjectGraphEnv: string | undefined; + + beforeEach(() => { + nxCacheProjectGraphEnv = process.env.NX_CACHE_PROJECT_GRAPH; + process.env.NX_CACHE_PROJECT_GRAPH = 'false'; + workspaceRoot = mkdtempSync(join(tmpdir(), 'oxlint-plugin-')); + context = { + nxJsonConfiguration: { + targetDefaults: { + oxlint: { + cache: false, + }, + }, + }, + workspaceRoot, + }; + }); + + afterEach(() => { + jest.resetModules(); + rmSync(workspaceRoot, { recursive: true, force: true }); + if (nxCacheProjectGraphEnv === undefined) { + delete process.env.NX_CACHE_PROJECT_GRAPH; + } else { + process.env.NX_CACHE_PROJECT_GRAPH = nxCacheProjectGraphEnv; + } + }); + + it('should not create nodes without config files', async () => { + createFiles({ + 'libs/a/project.json': `{"name":"a"}`, + }); + + const results = await invokeCreateNodesOnMatchingFiles(context, { + targetName: 'oxlint', + }); + expect(results).toEqual({ projects: {} }); + }); + + it('should create a target for a project when config exists', async () => { + createFiles({ + '.oxlintrc.json': `{"rules":{}}`, + 'libs/a/project.json': `{"name":"a"}`, + }); + + const results = await invokeCreateNodesOnMatchingFiles(context, { + targetName: 'oxlint', + }); + + expect(results.projects['libs/a'].targets.oxlint).toMatchObject({ + command: 'oxlint libs/a', + cache: true, + }); + }); + + it('should create a target when using oxlint.config.ts', async () => { + createFiles({ + 'oxlint.config.ts': `export default { rules: {} };`, + 'libs/a/project.json': `{"name":"a"}`, + }); + + const results = await invokeCreateNodesOnMatchingFiles(context, { + targetName: 'oxlint', + }); + + expect(results.projects['libs/a'].targets.oxlint).toBeDefined(); + }); + + it('uses custom targetName', async () => { + createFiles({ + '.oxlintrc.json': `{"rules":{}}`, + 'libs/a/project.json': `{"name":"a"}`, + }); + + const results = await invokeCreateNodesOnMatchingFiles(context, { + targetName: 'lint', + }); + + expect(results.projects['libs/a'].targets.lint).toBeDefined(); + }); + + it('creates a root-project target that points at ./src', async () => { + createFiles({ + '.oxlintrc.json': `{"rules":{}}`, + 'package.json': `{"name":"root-workspace"}`, + 'src/index.ts': `export const value = 1;`, + }); + + const results = await invokeCreateNodesOnMatchingFiles(context, { + targetName: 'oxlint', + }); + + expect(results.projects['.'].targets.oxlint).toMatchObject({ + command: 'oxlint ./src', + }); + }); + + function createFiles(files: Record) { + Object.entries(files).forEach(([filePath, fileContent]) => { + const absPath = join(workspaceRoot, filePath); + mkdirSync(join(absPath, '..'), { recursive: true }); + writeFileSync(absPath, fileContent, 'utf-8'); + }); + configFiles = Object.keys(files).filter((file) => + minimatch(file, createNodesV2[0], { dot: true }) + ); + } + + async function invokeCreateNodesOnMatchingFiles( + context: CreateNodesContextV2, + options = {} + ) { + const aggregateProjects: Record< + string, + { targets: Record } + > = {}; + const results = await createNodesV2[1](configFiles, options, context); + for (const [, nodes] of results) { + Object.assign(aggregateProjects, nodes.projects); + } + return { + projects: aggregateProjects, + }; + } +}); diff --git a/packages/oxlint/src/plugins/plugin.ts b/packages/oxlint/src/plugins/plugin.ts new file mode 100644 index 000000000..162c00813 --- /dev/null +++ b/packages/oxlint/src/plugins/plugin.ts @@ -0,0 +1,265 @@ +import { + CreateNodesContextV2, + createNodesFromFiles, + CreateNodesResult, + CreateNodesV2, + getPackageManagerCommand, + readJsonFile, + TargetConfiguration, + writeJsonFile, +} from '@nx/devkit'; +import { existsSync } from 'node:fs'; +import { basename, dirname, join, normalize, sep } from 'node:path/posix'; +import { hashObject } from 'nx/src/hasher/file-hasher'; +import { workspaceDataDirectory } from 'nx/src/utils/cache-directory'; +import { combineGlobPatterns } from 'nx/src/utils/globs'; +import { OXLINT_CONFIG_FILENAMES } from '../utils/config-file'; + +const pmc = getPackageManagerCommand(); +const PROJECT_CONFIG_FILENAMES = ['project.json', 'package.json']; +const TARGETS_CACHE_VERSION = 2; +const OXLINT_CONFIG_GLOB_V2 = combineGlobPatterns([ + ...OXLINT_CONFIG_FILENAMES.map((f) => `**/${f}`), + ...PROJECT_CONFIG_FILENAMES.map((f) => `**/${f}`), +]); + +export interface OxlintPluginOptions { + targetName?: string; +} + +function readTargetsCache( + cachePath: string +): Record { + return process.env.NX_CACHE_PROJECT_GRAPH !== 'false' && existsSync(cachePath) + ? readJsonFile(cachePath) + : {}; +} + +function writeTargetsToCache( + cachePath: string, + results: Record +) { + writeJsonFile(cachePath, results); +} + +export const createNodes: CreateNodesV2 = [ + OXLINT_CONFIG_GLOB_V2, + async (configFiles, options, context) => { + options = normalizeOptions(options); + const optionsHash = hashObject({ + ...options, + __cacheVersion: TARGETS_CACHE_VERSION, + }); + const cachePath = join( + workspaceDataDirectory, + `oxlint-${optionsHash}.hash` + ); + const targetsCache = readTargetsCache(cachePath); + + try { + const { oxlintConfigFiles, projectRoots } = splitConfigFiles(configFiles); + if (oxlintConfigFiles.length === 0 || projectRoots.length === 0) { + return []; + } + + const projectRootsByConfig = groupProjectRootsByConfig( + oxlintConfigFiles, + projectRoots + ); + + return await createNodesFromFiles( + async (configFilePath, options, context) => { + const projects: CreateNodesResult['projects'] = {}; + const projectRoots = projectRootsByConfig.get(configFilePath) ?? []; + + for (const projectRoot of projectRoots) { + const hash = `${projectRoot}:${configFilePath}:${optionsHash}`; + if (targetsCache[hash]) { + Object.assign(projects, targetsCache[hash]); + continue; + } + + const project = getProjectUsingOxlintConfig( + projectRoot, + oxlintConfigFiles.filter((config) => + isSubDir(dirname(config), projectRoot) + ), + options, + context + ); + + if (project) { + projects[projectRoot] = project; + targetsCache[hash] = { [projectRoot]: project }; + } else { + targetsCache[hash] = {}; + } + } + + return { projects }; + }, + oxlintConfigFiles, + options, + context + ); + } finally { + writeTargetsToCache(cachePath, targetsCache); + } + }, +]; + +export const createNodesV2 = createNodes; + +function splitConfigFiles(configFiles: readonly string[]): { + oxlintConfigFiles: string[]; + projectRoots: string[]; +} { + const oxlintConfigFiles: string[] = []; + const projectRoots = new Set(); + + for (const configFile of configFiles) { + if (PROJECT_CONFIG_FILENAMES.includes(basename(configFile))) { + projectRoots.add(dirname(configFile)); + } else { + oxlintConfigFiles.push(configFile); + } + } + + return { + oxlintConfigFiles, + projectRoots: Array.from(projectRoots), + }; +} + +function groupProjectRootsByConfig( + oxlintConfigFiles: string[], + projectRoots: string[] +): Map { + const byConfig = new Map(); + + for (const configFile of oxlintConfigFiles) { + byConfig.set(configFile, []); + } + + for (const projectRoot of projectRoots) { + const nearest = getNearestConfigForProject(projectRoot, oxlintConfigFiles); + if (nearest) { + byConfig.get(nearest).push(projectRoot); + } + } + + return byConfig; +} + +function getNearestConfigForProject( + projectRoot: string, + configFiles: string[] +): string | undefined { + const matchingConfigs = configFiles.filter((config) => + isSubDir(dirname(config), projectRoot) + ); + if (matchingConfigs.length === 0) { + return undefined; + } + return matchingConfigs.sort( + (a, b) => dirname(b).length - dirname(a).length + )[0]; +} + +function getProjectUsingOxlintConfig( + projectRoot: string, + oxlintConfigs: string[], + options: OxlintPluginOptions, + context: CreateNodesContextV2 +): CreateNodesResult['projects'][string] | null { + let standaloneSrcPath: string | undefined; + if ( + projectRoot === '.' && + existsSync(join(context.workspaceRoot, projectRoot, 'package.json')) + ) { + if (existsSync(join(context.workspaceRoot, projectRoot, 'src'))) { + standaloneSrcPath = 'src'; + } else if (existsSync(join(context.workspaceRoot, projectRoot, 'lib'))) { + standaloneSrcPath = 'lib'; + } + } + + if (projectRoot === '.' && !standaloneSrcPath) { + return null; + } + + const sortedConfigs = [...oxlintConfigs].sort( + (a, b) => dirname(a).length - dirname(b).length + ); + + return { + targets: buildOxlintTargets( + sortedConfigs, + projectRoot, + options, + standaloneSrcPath + ), + }; +} + +function buildOxlintTargets( + oxlintConfigs: string[], + projectRoot: string, + options: OxlintPluginOptions, + standaloneSrcPath?: string +): Record { + const isRootProject = projectRoot === '.'; + const lintPath = + isRootProject && standaloneSrcPath + ? `./${standaloneSrcPath}` + : isRootProject + ? '.' + : projectRoot; + const targetConfig: TargetConfiguration = { + command: `oxlint ${lintPath}`, + cache: true, + inputs: [ + 'default', + '^default', + ...oxlintConfigs.map((config) => `{workspaceRoot}/${config}`), + { externalDependencies: ['oxlint'] }, + ], + metadata: { + technologies: ['oxlint'], + description: 'Runs Oxlint on project', + help: { + command: `${pmc.exec} oxlint --help`, + example: { + options: { + 'max-warnings': 0, + }, + }, + }, + }, + }; + + return { + [options.targetName]: targetConfig, + }; +} + +function normalizeOptions(options: OxlintPluginOptions): OxlintPluginOptions { + return { + targetName: options?.targetName ?? 'oxlint', + }; +} + +function isSubDir(parent: string, child: string): boolean { + if (parent === '.') { + return true; + } + + parent = normalize(parent); + child = normalize(child); + + if (!parent.endsWith(sep)) { + parent += sep; + } + + return child.startsWith(parent); +} diff --git a/packages/oxlint/src/utils/config-file.ts b/packages/oxlint/src/utils/config-file.ts new file mode 100644 index 000000000..438a3c12e --- /dev/null +++ b/packages/oxlint/src/utils/config-file.ts @@ -0,0 +1,5 @@ +export const OXLINT_CONFIG_FILENAMES = [ + '.oxlintrc.json', + '.oxlintrc.jsonc', + 'oxlint.config.ts', +]; diff --git a/packages/oxlint/src/utils/plugin.ts b/packages/oxlint/src/utils/plugin.ts new file mode 100644 index 000000000..84745d5b1 --- /dev/null +++ b/packages/oxlint/src/utils/plugin.ts @@ -0,0 +1,10 @@ +import { Tree, readNxJson } from '@nx/devkit'; + +export function hasOxlintPlugin(tree: Tree): boolean { + const nxJson = readNxJson(tree); + return nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/oxlint/plugin' + : p.plugin === '@nx/oxlint/plugin' + ); +} diff --git a/packages/oxlint/src/utils/versions.ts b/packages/oxlint/src/utils/versions.ts new file mode 100644 index 000000000..65c3bbc9a --- /dev/null +++ b/packages/oxlint/src/utils/versions.ts @@ -0,0 +1,4 @@ +import { version } from '../../package.json'; + +export const nxVersion = version; +export const oxlintVersion = '^1.55.0'; diff --git a/packages/oxlint/tsconfig.json b/packages/oxlint/tsconfig.json new file mode 100644 index 000000000..95092cbe1 --- /dev/null +++ b/packages/oxlint/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "types": ["node", "jest"] + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "nx": { + "addTypecheckTarget": false + } +} diff --git a/packages/oxlint/tsconfig.lib.json b/packages/oxlint/tsconfig.lib.json new file mode 100644 index 000000000..a21c9ce01 --- /dev/null +++ b/packages/oxlint/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "resolveJsonModule": true, + "declaration": true, + "types": ["node"], + "tsBuildInfoFile": "../../dist/packages/oxlint/tsconfig.tsbuildinfo" + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*_spec.ts", + "**/*_test.ts", + "jest.config.ts", + "jest.config.cts" + ], + "include": ["*.ts", "src/**/*.ts", "src/**/*.json"] +} diff --git a/packages/oxlint/tsconfig.spec.json b/packages/oxlint/tsconfig.spec.json new file mode 100644 index 000000000..7e428e217 --- /dev/null +++ b/packages/oxlint/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "resolveJsonModule": true, + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "*.d.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.d.ts", + "src/**/*.json" + ] +} diff --git a/tools/scripts/start-local-registry.ts b/tools/scripts/start-local-registry.ts index 903d7aaef..35c1cd74d 100644 --- a/tools/scripts/start-local-registry.ts +++ b/tools/scripts/start-local-registry.ts @@ -6,6 +6,8 @@ /// import { startLocalRegistry } from '@nx/js/plugins/jest/local-registry'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; import { releasePublish, releaseVersion } from 'nx/release'; export default async () => { @@ -13,6 +15,11 @@ export default async () => { const localRegistryTarget = 'nx-labs:local-registry'; // storage folder for the local registry const storage = './tmp/local-registry/storage'; + const userConfig = join(process.cwd(), 'tmp/local-registry/.npmrc'); + + mkdirSync(join(process.cwd(), 'tmp/local-registry'), { recursive: true }); + process.env.npm_config_userconfig = userConfig; + process.env.NPM_CONFIG_USERCONFIG = userConfig; global.stopLocalRegistry = await startLocalRegistry({ localRegistryTarget, diff --git a/tsconfig.base.json b/tsconfig.base.json index d3d842eb0..dee231526 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -15,6 +15,11 @@ "skipDefaultLibCheck": true, "baseUrl": ".", "paths": { + "@nx/oxlint": ["packages/oxlint/index.ts"], + "@nx/oxlint/plugin": ["packages/oxlint/plugin.ts"], + "@nx/oxlint/boundaries-plugin": [ + "packages/oxlint/src/boundaries-plugin/index.ts" + ], "@nx/composer/generators": ["packages/composer/src/generators/index.ts"], "@nx/composer": ["packages/composer/src/index.ts"], "@nx/phpunit": ["packages/phpunit/src/index.ts"],