feat(linter): add the @nx/oxlint plugin - #36491
Conversation
✅ Deploy Preview for nx-docs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for nx-dev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
View your CI Pipeline Execution ↗ for commit d958c99
☁️ Nx Cloud last updated this comment at |
d210b9f to
9b8d7c4
Compare
| {% aside type="caution" title="It skips silently without a project graph" %} | ||
| When no cached project graph is available, the rule reports nothing and the task still passes. The notice it prints is not a lint diagnostic, so `--deny-warnings` and `--max-warnings` don't catch it either. Run it behind `nx lint` rather than invoking `oxlint` directly, and don't rely on it as your only boundary check until that's fixed. |
There was a problem hiding this comment.
| {% aside type="caution" title="It skips silently without a project graph" %} | |
| When no cached project graph is available, the rule reports nothing and the task still passes. The notice it prints is not a lint diagnostic, so `--deny-warnings` and `--max-warnings` don't catch it either. Run it behind `nx lint` rather than invoking `oxlint` directly, and don't rely on it as your only boundary check until that's fixed. |
|
|
||
| The ESLint rule loads the project graph through the same code path, so switching linters doesn't avoid this. | ||
| {% /aside %} |
There was a problem hiding this comment.
| The ESLint rule loads the project graph through the same code path, so switching linters doesn't avoid this. | |
| {% /aside %} |
| `@nx/eslint-plugin` is an optional peer of `@nx/oxlint`, so no package manager installs it for you. `nx add` takes one package at a time, so add both: | ||
|
|
||
| ```shell | ||
| nx add @nx/oxlint | ||
| nx add @nx/eslint-plugin | ||
| ``` |
There was a problem hiding this comment.
| `@nx/eslint-plugin` is an optional peer of `@nx/oxlint`, so no package manager installs it for you. `nx add` takes one package at a time, so add both: | |
| ```shell | |
| nx add @nx/oxlint | |
| nx add @nx/eslint-plugin | |
| ``` |
This is weird.. perhaps it should be a dependency
|
|
||
| {% aside type="note" %} | ||
| This rule requires ESLint and only works for JavaScript/TypeScript projects. For language-agnostic boundary enforcement across all project dependencies, see the [Conformance plugin's Enforce Project Boundaries rule](/docs/enterprise/conformance). | ||
| This rule only works for JavaScript/TypeScript projects. It runs on ESLint, and experimentally on [Oxlint](/docs/technologies/oxlint/introduction). On both, it reports nothing and still passes if no cached project graph is available, so don't rely on it as your only boundary check. For language-agnostic boundary enforcement across all project dependencies, see the [Conformance plugin's Enforce Project Boundaries rule](/docs/enterprise/conformance). |
There was a problem hiding this comment.
| This rule only works for JavaScript/TypeScript projects. It runs on ESLint, and experimentally on [Oxlint](/docs/technologies/oxlint/introduction). On both, it reports nothing and still passes if no cached project graph is available, so don't rely on it as your only boundary check. For language-agnostic boundary enforcement across all project dependencies, see the [Conformance plugin's Enforce Project Boundaries rule](/docs/enterprise/conformance). | |
| This rule only works for JavaScript/TypeScript projects. It runs on ESLint, and experimentally on [Oxlint](/docs/technologies/oxlint/introduction). For language-agnostic boundary enforcement across all project dependencies, see the [Conformance plugin's Enforce Project Boundaries rule](/docs/enterprise/conformance). |
| --- | ||
|
|
||
| [Oxlint](https://oxc.rs/docs/guide/usage/linter/) is a linter written in Rust. | ||
| The `@nx/oxlint` plugin runs it as a cached Nx task, so a change re-lints only the projects it affects. |
There was a problem hiding this comment.
| The `@nx/oxlint` plugin runs it as a cached Nx task, so a change re-lints only the projects it affects. | |
| The `@nx/oxlint` plugin runs it as a cacheable Nx task, so a change re-lints only the projects it affects. |
| unitTestRunner: options.unitTestRunner, | ||
| ...options, |
There was a problem hiding this comment.
Wouldn't this already be in ...options?
| unitTestRunner: options.unitTestRunner, | ||
| ...options, |
There was a problem hiding this comment.
Wouldn't this already be in ...options?
| // `LinterType` comes from `@nx/js`, not `@nx/eslint`: this package is the one | ||
| // `@nx/eslint` importer that does not declare it as a dependency, so the | ||
| // specifier resolves to the published tarball, whose `LinterType` predates | ||
| // `oxlint` and would contradict the enum in schema.json. | ||
| import type { Linter } from '@nx/eslint'; |
There was a problem hiding this comment.
Mm.. we should just not import from '@nx/eslint'?
| @@ -0,0 +1,89 @@ | |||
| import 'nx/src/internal-testing-utils/mock-project-graph'; | |||
There was a problem hiding this comment.
can this be imported from @nx/devkit/internals instead?
| return 'oxlint'; | ||
| } | ||
|
|
||
| return 'eslint'; |
There was a problem hiding this comment.
Shouldn't this also check if eslint is being used or not? if not, it should return none?
|
|
||
| ## Customizing the task | ||
|
|
||
| `@nx/oxlint` has no executor. Every Oxlint task comes from inference, so the plugin is registered even when `useInferencePlugins` is disabled. |
There was a problem hiding this comment.
| `@nx/oxlint` has no executor. Every Oxlint task comes from inference, so the plugin is registered even when `useInferencePlugins` is disabled. |
| To change how one project is linted, override the inferred target in its `project.json`: | ||
|
|
||
| ```json | ||
| { | ||
| "targets": { | ||
| "lint": { | ||
| "command": "oxlint --type-aware ." | ||
| } | ||
| } | ||
| } | ||
| ``` |
There was a problem hiding this comment.
Use args as an example instead.. otherwise too much of the target will get overwritten.a
| ## Add Oxlint to an ESLint workspace | ||
|
|
||
| Oxlint runs alongside ESLint, so the setup above is all you need and nothing about your ESLint config changes. ESLint keeps the `lint` target, so Oxlint registers as `oxlint` and every project with lintable files gets one. | ||
|
|
||
| Nx doesn't translate ESLint rules into Oxlint rules. To generate an `.oxlintrc.json` from your existing ESLint config, run [`@oxlint/migrate`](https://github.com/oxc-project/oxlint-migrate). To stop ESLint from re-reporting what Oxlint already covers, add [`eslint-plugin-oxlint`](https://www.npmjs.com/package/eslint-plugin-oxlint) to the ESLint config. |
There was a problem hiding this comment.
this is still a weird section... I think maybe let's remove it for now until we know what the questions need to be answered.
| tree: Tree, | ||
| options: CyLinterOptions | ||
| ) { | ||
| if (options.linter === 'none') { |
There was a problem hiding this comment.
maybe this should stay?
| Tree, | ||
| } from '@nx/devkit'; | ||
| import { Linter, LinterType, lintProjectGenerator } from '@nx/eslint'; | ||
| import { Linter, LinterType } from '@nx/eslint'; |
There was a problem hiding this comment.
why does this come from eslint? It should probably only LinterType from js
| export function configurationGenerator( | ||
| tree: Tree, | ||
| options: ConfigurationGeneratorSchema | ||
| ) { | ||
| return configurationGeneratorInternal(tree, options); | ||
| } |
There was a problem hiding this comment.
Do we need an internal one vs a public one? they're the same.. so probably not
71a1f01 to
73f416e
Compare
7b1787b to
711cc46
Compare
c7de098 to
f3973de
Compare
@nx/next enables the jsx-a11y oxlint plugin, and the welcome page embeds five inline SVGs carrying role="img". An SVG has no tag to swap the role for, so the rule is suppressed at the top of the page, matching what the react and remix welcome templates already do. Both routers inject the same content, so both wrappers carry it.
The rebase resolved every pnpm-lock.yaml conflict in favour of upstream, which dropped the root oxlint entry that package.json still declares. Regenerated so a frozen install resolves; verified with pnpm install --frozen-lockfile.
…-Healing CI Rerun]
The install-avoidance rationale is true where @nx/eslint is a devDependency reached only through require. In @nx/react it is a hard dependency and this file imports it statically, so ensurePackage can never install it and the guard is a plain early-out.
Every other converted schema.d.ts imports LinterType from @nx/js; this one was hand-widened to an inline union, and @nx/remix already depends on @nx/js. Also corrects both sync comments: three copies exist, not two, and nothing enforces that they agree.
Six of AddLintingToProjectOptions' ten fields are documented as ignored depending on the linter, so an option bag that type-checks can silently do nothing. That is first-party generator plumbing, not a shape worth committing to publicly. Moved beside detectLinters on @nx/js/internal, which the file's own comment already gives the reason for. LinterType stays public.
…target A project owning a config got a target regardless of content, so a docs project carrying an .oxlintrc.json produced a task that exits 1 with "No files found to lint". The comment above the gate already said docs-only projects should get none; the config-owning arm defeated it. The gate now requires lintable files in every case, and the count comes from oxlint --debug=files, which honours ignorePatterns, .eslintignore and .gitignore including nested project configs. One root invocation, ~40ms on 4k files. It falls back to the previous glob whenever oxlint cannot answer: not installed, wedged, or a single malformed config anywhere aborting the run. That path cannot see ignore rules, so the inferred command carries --no-error-on-unmatched-pattern, which still exits 1 on a real violation.
…c entry Three call sites import it across several lines, so the earlier same-line rewrite missed them: @nx/plugin's e2e-project, @nx/web's application and @nx/remix's application. They failed to compile, and @nx/plugin failed at runtime with addLintingToProject is not a function. Also sets windowsHide on the oxlint enumeration spawn, which the repo's own require-windows-hide rule requires.
@nx/workspace keeps a standalone copy because @nx/js depends on it, not the other way round, and the comment asking to keep them in sync was the only thing holding it. The assertion sits in linter.ts rather than a spec because tsconfig.lib.json is what CI compiles: spec files are excluded from it, packages/js has no typecheck target, and jest strips types with swc without reading them. Verified by mutation under tsgo -b, the compiler the build actually uses — adding or removing a member on either side fails the build, and nothing is emitted. Exporting LinterType from @nx/workspace's root gives the assertion a resolvable specifier; the root already exported the deprecated Linter enum beside it.
packages/oxlint declares no name, so nx derives @nx/oxlint from its package.json. dependsOn listed "oxlint", which matches no project, and findMatchingProjects returns an empty list without warning — so the docs build never waited on the package it needs.
These were the only linter-bearing schemas left without an enum, so a typo reached the dispatcher and failed there instead of at the CLI. create-nx-workspace already gates the same flag through its yargs choices, so this only aligns the direct generator path with it.
Two it.each blocks were byte-identical in body and differed only in title, which reads as if the second covers something the first does not.
addLintingToProject returns a no-op for 'none' at add-linting-to-project.ts:84, so the second half of the condition never changed the outcome.
…ommand The flag does not exist below oxlint 1.60.0, while this package declares and asserts a floor of 1.43.0. Measured: 1.43.0 through 1.59.0 reject it with "not expected in this context" and exit 1, so every inferred lint target failed outright on a supported version rather than linting. 1.60.0 is the first release whose --help lists it. It was only a backstop for the glob fallback, which cannot see ignore rules. The inference gate still keeps targets off projects with nothing to lint whenever the oxlint enumeration is available; a fully-ignored project on the fallback path keeps failing, which is what NXC-4784 tracks.
The page said a project gets a task when it owns a config or has lintable files, and that a docs-only project opts in by adding a config of its own. Owning a config stopped being sufficient when the inference gate changed, so following the page produced no target at all.
Inference enumerates lintable files by spawning oxlint --debug=files, which needs two things that only line up from 1.70.0: the --debug flag, and a ./package.json entry in oxlint's exports so require.resolve can locate the binary. Measured across published releases: 1.43.0 has neither, 1.60.0 has the exports entry only, 1.70.0 has both. Below the new floor both fail into the glob fallback, so the plugin kept working while silently losing the ignore-awareness the docs describe. The declared range now matches what the plugin actually needs.
The generator's description promised linting unconditionally. It resolves the linter like every other generator, so a workspace with none that cannot prompt resolves to none and the generator exits having written nothing. Both copies of the description now say so.
The example claims it adds ESLint but omitted --linter, so it only held in a workspace that already used ESLint. The example is about Angular-specific ESLint rules, so stating the linter keeps it meaningful and true anywhere.
The example claimed it adds ESLint. It sets up whichever linter the workspace uses; only the ESLint arm adds the Angular-specific selector rules, and the prefix feeds those rules alone, so it is inert under oxlint.
40601bb to
1e552dc
Compare
Moving addLintingToProject to @nx/js/internal added a second import statement to files that already imported from that module, so several ended up importing the same module twice. Merged back into one statement per module.
…Healing CI Rerun]
There was a problem hiding this comment.
Nx Cloud has identified a flaky task in your failed CI:
🔂 Since the failure was identified as flaky, we triggered a CI rerun by adding an empty commit to this branch.
🔔 Heads up, your workspace has pending recommendations ↗ to auto-apply fixes for similar failures.
🎓 Learn more about Self-Healing CI on nx.dev
Current Behavior
@nx/oxlintresides in the nx-labs repository, shipping on its own cadence and unavailable through generators. No Nx generator offers an option to request an Oxlint project.Additionally, the
linteroption has never reflected workspace choices. Every generator carried static JSON-schema defaults, sonx g @nx/react:libin an Oxlint-adopting workspace still produced ESLint projects. Workspaces created with--linter=nonehad ESLint inferred on first generator run without explicit flags. Plugin workspace creation ignored the choice entirely—@nx/plugin:presethardcoded ESLint.Expected Behavior
@nx/oxlintgraduates intopackages/oxlint, shipping experimental. Additions include an inference plugin detecting Oxlint from config files, a configuration generator, and a bridge enabling Nx'senforce-module-boundariesrule under Oxlint's JS-plugin API.Project generators now follow workspace linter preferences. The new
detectLintersfunction reads the workspace and returns every linter it has, most-preferred first, so[0]is the one a generator should follow:oxlinteslintPreviously, detection only distinguished between Oxlint and ESLint, collapsing "uses ESLint" and "uses nothing" into one category.
Verification involved 17 generators tested against all three workspace configurations without
--linterflags—51/51 matched workspace preferences. An additional 25-generator sweep confirmed detection never overrides explicit requests.Breaking-ish Changes
detectLintersreplaces four hand-rolledisEslintInstalledhelpers. Those probedrequire('eslint'), which resolves from the generator's own scope;eslintis a peer dependency of several first-party plugins, so they returned true in workspaces that do not use it.detectLintersreads the tree and is exported through@nx/js/internal.12 generators declare
linteras optional on inputschema.d.tswhere previously required, though required onNormalizedSchema. This is strictly more permissive; missing resolution now becomes a compile error rather than silent failure.Non-interactive
nx g <framework>:app|libin linter-free workspaces now generates no linter, where previously ESLint was generated. User-visible on paths unrelated to Oxlint; warrants a release note.Generators no longer ask which linter to use when the workspace already has one. Following the workspace is not a question; the prompt is reserved for a workspace with no linter, where there is a real choice. Pass
--linterto override. Cost: opting a single project out interactively now needs--linter=none.The linter prompt moved from schemas into every generator. Previously, JSON
x-promptalways highlighted the first enum value regardless of workspace setup. Generators now resolve throughnormalizeLinterOption.@nx/angular:hostresolves the linter once for host and remotes. Angular'snormalizeOptionsreturns a new object, so resolved values never reached callers—each delegation resolved independently, allowing divergent answers.create-nx-pluginnow prompts for Nx Cloud in an interactive terminal. Declaring--interactive(below) also un-skipsdetermineNxCloud, which read the same flag. The prompt was never a decision to omit—it was dead because the flag did not exist—and this bringscreate-nx-pluginin line withcreate-nx-workspace.@nx/webno longer scaffolds a Jestsrc/test-setup.ts. Itsweb-componentssetup file only ever held thedocument-register-elementpolyfill, which was removed from the template long ago; the value has since produced an empty file wired intosetupFilesAfterEnv. The file, itssetupFilesAfterEnventry and itstsconfig.spec.jsoninclude are all gated on the same value and go together.@nx/oxlintdeclaresengines: { node: "^20.19.0 || >=22.12.0" }. It's the only ESM package underpackages/, requiring synchronousrequirecompatibility.Workspace Creation
Plugin workspace creation now respects
--linter. The option threads through@nx/plugin:preset, with resolution occurring once—child generators prompt independently.create-nx-plugingained--interactive(default true), which it never declared. Without it, prompts reading this flag sawundefinedand skipped themselves.A detected
noneno longer pins intonx.json. Recordingeslintoroxlintpreserves real choices, but freezingnoneprevents workspaces from adopting linting later.The linter question now precedes the test-runner questions. It previously trailed
unitTestRunnerande2eTestRunner, sitting beside the formatter—a workspace-level question deliberately asked last. Every stack now asks it after the appearance questions (bundler, style, SSR) and before the test runners, so linter, unit-test runner and e2e runner read as one block. The react stack's preset branch was split to make this possible: the bundler resolves first, sincedetermineUnitTestRunnerstill keys offpreferVitest: bundler === 'vite'. The Next.js and React Native/Expo arms were identical and merged rather than duplicated.Each stack resolves its own linter inside its
determine*Optionsfunction, alongside every other per-stack option (appName,framework,unitTestRunner,e2eTestRunner,formatter). Resolution is deliberately not hoisted above the preset switch:apps,tsandnpmreach no generator that takes a linter, so a hoisted call would ask a question and discard the answer on the most travelled path in the CLI.--preset=web-componentsreceived its ownwebstack. It was the onlyPresetmember mapped to theunknowncatch-all, which skips option resolution—harmless until this PR replaced@nx/web:application's internal|| 'eslint'with detection, at which point it scaffolded unlinted. All 24Presetmembers are now explicitly cased.--preset=ts-standalonepreviously asked twice—parent and child each resolved independently. The preset now forwards--linterso the child short-circuits.--no-workspacesnow asks about linters. Previously, only workspaced stacks determined linters; this layout independence is now respected.At creation time, ESLint remains the default when prompts cannot run (CI,
--no-interactive)—determineLinterOptionskeepsinitial: 0deliberately while Oxlint remains experimental.Generated Code Passes Lint
Generating a project and immediately linting it surfaced three failures, all measured against oxlint 1.75 rather than reasoned about.
<div role="navigation">, whichjsx-a11y/prefer-tag-over-rolereports; it is now a<nav>, which carries that role implicitly.create-nx-workspacedefaults React workspaces to routing, so every generated app hit this. Nothing selected the wrapper by role — no stylesheet, no attribute selector, no test query — andaddRoutelocatesRouteandLinkby tag name.@nx/nextwelcome page lacked theprefer-tag-over-rolesuppression that the React and Remix welcome templates already carry.@nx/nextenables thejsx-a11yplugin and the page embeds five inline SVGs withrole="img", which has no tag to swap for. Both routers inject the same content, so both wrappers carry it. Sweeping every package,jsx-a11yis enabled only for react, next and remix — expo and react-native getreact/react-perf, vue and nuxt getvue, and therole=attributes in angular and web live in template literals rather than JSX.unicorn/no-empty-file.Documentation
New Oxlint pages cover setup, inferred tasks, config format, task naming, type-aware linting, and module-boundaries bridging. Four knowledge-base pages show
create-nx-workspaceterminal transcripts with the linter prompt in the position it actually occupies—before the test-runner questions. The prompt is absent from--preset=appstranscripts, where it cannot fire.Related Issue(s)
Relates to NXC-4312
Fixes NXC-4774 — Oxlint a11y rules give contradictory guidance on
role="navigation"vs<nav>. Note the reported second, circular violation did not reproduce:<nav>and even<nav role="navigation">are both clean, and oxlint has nono-redundant-rolesrule.Fixes NXC-4776 — Oxlint
unicorn/no-empty-fileflags the Jest test-setup file in the generated web app.Known follow-up, deliberately not addressed here: NXC-4784 — the inference plugin decides on lint targets by extension count alone, so a project whose files are all ignored still gets a target, and that target fails.
View session information ↗