Skip to content

feat(linter): add the @nx/oxlint plugin - #36491

Draft
FrozenPandaz wants to merge 56 commits into
masterfrom
feature/nxc-4312-add-oxlint-plugin
Draft

feat(linter): add the @nx/oxlint plugin#36491
FrozenPandaz wants to merge 56 commits into
masterfrom
feature/nxc-4312-add-oxlint-plugin

Conversation

@FrozenPandaz

Copy link
Copy Markdown
Contributor

Current Behavior

@nx/oxlint lives in nx-labs (nrwl/nx-labs#441, by juristr), open since March with no maintainer review. It closes discussions #21439 and #35480 and issue #21384.

There is also no way to ask for Oxlint from a generator: LinterType is 'eslint' | 'none', and nx g @nx/react:library --linter oxlint fails schema validation.

Expected Behavior

Graduates the plugin into packages/oxlint, shipped experimental, and widens the generator linter axis so --linter oxlint works.

The plugin

Inference plugin, lint executor, and init / lint-project / convert-from-eslint generators, mirroring packages/eslint. It is the first ESM package in the repo, so every load path was verified rather than assumed:

Path Mechanism Result
Build tsgo, module: nodenext ESM emit
Inference plugin loadResolvedNxPluginAsyncawait handleImport() createNodes tuple resolves
Executors / generators getImplementationFactoryrequire() require(esm); Nx already does module.default ?? module
Boundaries bridge Oxlint's (await import(url)).default real ESM default
Jest SWC → CJS 29/29, needs a .js→TS moduleNameMapper

Caveat for review: require(esm) is unflagged only on Node ≥20.19 / ≥22.12. Below that, generators and executors would fail with ERR_REQUIRE_ESM. Nx declares no engines, so if that band is still supported this package wants one.

Defects fixed relative to the labs source

  • The boundaries bridge imported @nx/eslint-plugin/src/rules/enforce-module-boundaries, which is not in that package's exports map — it resolved in-repo via the @nx/nx-source condition and would have broken once published. Now uses the public @nx/eslint-plugin/nx export.
  • addPlugin came from the removed @nx/devkit/src/utils/add-plugin deep path.
  • The executor passed --experimental-nested-config, removed in Oxlint v0.16.0. Nested config is the default; --disable-nested-config and --type-aware are exposed instead.
  • convert-from-eslint discarded the install callback, so Oxlint was written to package.json but never installed.
  • nxVersion resolved to the literal 0.0.1.
  • Oxlint pin ^1.55.0^1.75.0; oxlint.config.mts added to the recognized config filenames.
  • Target naming unified on lint, with addPlugin falling through to oxlint when ESLint owns it.

The inference plugin is rebuilt on the @nx/eslint patterns: a lintable-file check so non-JS projects stop getting targets, content-aware cache keys via calculateHashesForCreateNodes + PluginCache, tsconfig-chain inputs for type-aware linting, and recursive extends resolution — Oxlint only tracks that chain for its LSP, so Nx has to walk it for caching to be correct.

The linter axis

LinterType gains oxlint and moves canonically to @nx/js, the lowest package every linter consumer already depends on; @nx/eslint re-exports it. All 41 generator schemas that offer a linter accept oxlint, and the interactive prompt offers it.

A new addLintingToProject helper in @nx/js dispatches to the right plugin via ensurePackage (a static import would be circular). Its scope is deliberately narrow — registering the linter and its target. ESLint-specific config shaping (predefined configs, extends, ignore entries) stays at each call site guarded on the linter, because none of it has a cross-linter equivalent.

detectLinter replaces seven hardcoded ?? 'eslint' fallbacks, so a generator invoked without an explicit linter follows the workspace. Oxlint wins in a hybrid workspace; the fallback stays eslint when nothing is detected, preserving the historical default.

Dogfooding

examples/react/basic switches to Oxlint, and packages/oxlint lints itself (alongside ESLint, which keeps the package.json rules Oxlint architecturally cannot run — it does not parse JSON).

That gave the boundaries bridge its first runtime exercise. Verified with negative controls rather than a passing run: a bogus plugin path and a bogus rule name under @nx are each rejected at config-parse time, and create() is called per file returning all five visitors — meaning the Nx project graph resolves from inside Oxlint's JS-plugin runtime.

Verification

  • nx run-many -t lint,test,build -p @nx/oxlint — 29 tests
  • nx conformance — clean across 9 rules
  • nx run e2e-oxlint:e2e-local — 4/4
  • Whole-repo build-base — 43 projects

The e2e suite took three rounds: the first two versions passed while asserting against ESLint's target, because @nx/js:lib sets up ESLint by default. It now identifies the Oxlint target by metadata.technologies, generates fixtures with --linter=none, and asserts Oxlint's own no-debugger diagnostic plus task failure.

Known gaps

  • 16 generator call sites still gate on === 'eslint' and skip Oxlint; being converted separately.
  • convert-from-eslint adds targets alongside ESLint but does not translate rules — wrapping @oxlint/migrate is a follow-up.
  • @nx/vite, @nx/vitest and @nx/remix call addIgnoresToLintConfig from @nx/eslint/internal; these silently no-op in an Oxlint workspace.
  • No e2e asserts a reported boundary violation — that needs a two-project fixture.
  • @nx/oxlint is linked at the root as workspace:* rather than a pinned version, since it has never been published.

Related Issue(s)

Fixes NXC-4312


View session information ↗

@netlify

netlify Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploy Preview for nx-docs ready!

Name Link
🔨 Latest commit 0a17340
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/6a6ba682dc12280007f62c1b
😎 Deploy Preview https://deploy-preview-36491--nx-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploy Preview for nx-dev ready!

Name Link
🔨 Latest commit 0a17340
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/6a6ba6822ed752000881cf78
😎 Deploy Preview https://deploy-preview-36491--nx-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@nx-cloud

nx-cloud Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 0a17340

Command Status Duration Result
nx affected --targets=lint,test,build,e2e,e2e-c... ❌ Failed 46m 53s View ↗
nx run-many -t check-imports check-lock-files c... ❌ Failed 3s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ❌ Failed 59s View ↗
nx build workspace-plugin ❌ Failed <1s View ↗
nx-cloud record -- nx sync:check ❌ Failed 19s View ↗
nx-cloud record -- nx format:check ❌ Failed 14s View ↗
nx affected -t e2e-macos-local --parallel=2 --b... ✅ Succeeded 29m 1s View ↗

❌ Strict sandboxing stopped this CI run. View violations ↗


☁️ Nx Cloud last updated this comment at 2026-07-30 20:22:50 UTC

@socket-security

socket-security Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​oxlint@​1.75.0991009196100

View full report

@FrozenPandaz
FrozenPandaz force-pushed the feature/nxc-4312-add-oxlint-plugin branch 2 times, most recently from 651e173 to d210b9f Compare July 28, 2026 22:57
juristr and others added 23 commits July 29, 2026 00:22
Graduates the experimental @nx/oxlint plugin from nx-labs (nrwl/nx-labs#441,
by juristr) into packages/oxlint. Ships experimental.

The package is ESM (`"type": "module"`) — the first in the repo. Inference
plugins load via async import; executors and generators load through
`require(esm)`, which Nx already handles via `module.default ?? module`.

Fixes carried over from the labs implementation:

- The boundaries bridge imported `@nx/eslint-plugin/src/rules/...`, which is
  not in that package's exports map, so it resolved in-repo but would break
  once published. It now consumes the public `@nx/eslint-plugin/nx` export.
- `addPlugin` moved from the removed `@nx/devkit/src/utils/add-plugin` deep
  path to `@nx/devkit/internal`.
- The executor passed `--experimental-nested-config`, removed in oxlint
  v0.16.0. Nested config is the default; `--disable-nested-config` and
  `--type-aware` are exposed instead.
- `convert-from-eslint` discarded the install callback, so oxlint was written
  to package.json but never installed.
- `nxVersion` resolved to the literal `0.0.1` instead of the published version.
- The oxlint pin moves from ^1.55.0 to ^1.75.0, and `oxlint.config.mts` joins
  the recognized config filenames.
- The target name is unified on `lint`, with `addPlugin` falling through to
  `oxlint` when ESLint already owns it.

The inference plugin is rebuilt on the @nx/eslint patterns: a lintable-file
check so non-JS projects no longer get targets, content-aware cache keys via
calculateHashesForCreateNodes + PluginCache, tsconfig-chain inputs for
type-aware linting, and recursive `extends` resolution (oxlint only tracks
that chain for its LSP, so Nx has to walk it for caching to be correct).
…rget exists

The e2e suite resolved its target with `targets['lint'] ? 'lint' : 'oxlint'`.
`@nx/js:lib` sets up ESLint by default, so that heuristic selected ESLint's
target every time: the caching assertions ran against ESLint, and the
rule-violation case passed because ESLint flagged `debugger` — Oxlint was never
exercised.

Look the target up by `metadata.technologies` instead, generate the fixtures
with `--linter=none`, and read the command from `options.command` (which is
where `nx show project --json` puts it after resolving an inferred command
target onto `nx:run-commands`).

Assertions now discriminate against ESLint: the target must declare
`externalDependencies: ['oxlint']` and must not declare `eslint`, the failing
case must surface Oxlint's own `no-debugger` diagnostic, and the task must
actually fail. Adds coverage for the lintable-file check — a docs-only project
gets no Oxlint target.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
…in itself

examples/react/basic switches from ESLint to Oxlint: `.oxlintrc.json` replaces
`eslint.config.mjs`, `@nx/oxlint/plugin` replaces `@nx/eslint/plugin`, and the
`eslint` / `typescript-eslint` / `@nx/eslint` devDependencies give way to
`oxlint` + a link to the local plugin. `@nx/eslint-plugin` stays because the
module-boundaries bridge reuses its rule.

This is the first real exercise of the boundaries bridge. Verified in that
workspace that Oxlint resolves `@nx/oxlint/boundaries-plugin` through the
package exports map, resolves `@nx/enforce-module-boundaries` to the bridged
rule, and calls `create()` per file — returning all five visitors, which means
the Nx project graph resolves from inside Oxlint's JS-plugin runtime. Both
negative controls fail loudly: a bogus plugin path and a bogus rule name under
the `@nx` plugin are each rejected at config-parse time.

packages/oxlint additionally lints itself. `@nx/oxlint/plugin` is registered
scoped to that package with `targetName: 'oxlint'`, alongside the existing
ESLint `lint` target — ESLint still owns `@nx/dependency-checks` and
`@nx/nx-plugin-checks`, which lint package.json and which Oxlint cannot run at
all since it does not parse JSON. Fixes the shadowed bindings and the nested
test helper that dogfooding surfaced.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
Inferred oxlint targets carry `metadata.technologies: ['oxlint']`, which the
graph UI maps to an icon. Without an entry it fell through to
`UnknownTechnologyIcon`, so project details showed a generic placeholder.

Uses the Oxc mark from simple-icons (CC0) — the same provenance and 24x24
single-path shape as the existing ESLint icon — registered in both the graph
monochromatic icon map and the nx-dev icon barrel.
Adds `oxlint` to `LinterType` and to every generator schema that offers a
linter, so `nx g @nx/react:library --linter oxlint` is expressible and the
interactive prompt offers it alongside eslint and none.

`LinterType` now lives canonically in `@nx/js`, which is the lowest package
every linter consumer already depends on. `@nx/eslint` re-exports it, so
existing imports keep working. `@nx/workspace` keeps a standalone copy because
it does not depend on `@nx/js`.

Introduces `addLintingToProject` in `@nx/js`, a dispatch helper that routes to
the right linter plugin via `ensurePackage` (a static import would be circular,
since both plugins depend on `@nx/js`). Its scope is deliberately narrow —
registering the linter and its project target. ESLint-specific config shaping
stays at each call site guarded on the linter, because predefined configs,
`extends` and ignore entries have no cross-linter equivalent.

Migrates `@nx/react:library` to the helper as the reference shape. The
remaining consumers still gate on `=== 'eslint'` and are converted separately.

Also narrows the Angular storybook call site to eslint-or-none: it forwards
into `@nx/storybook`, which the repo resolves from node_modules, so its
published types cannot accept the widened union yet.
Generators resolve `ensurePackage('@nx/oxlint')` from their own location in
`packages/*/src`, which walks up to the repo root rather than into the target
workspace. Without a root link, picking oxlint in any generator fell through to
`installPackageToTmp` and tried to fetch a package that has never been
published.

Declared as `workspace:*` rather than a pinned version like its siblings
(`@nx/eslint: 23.2.0-beta.2`), since there is no published version to pin yet.
Move it onto the published-version convention once the package ships.
`isUsingTsSolutionSetup` requires both a package-manager workspace and a TS
solution tsconfig layout. The example satisfied neither, so generators run
against it took the legacy path.

Its `pnpm-workspace.yaml` was a marker file with no `packages` key, and
`isWorkspacesEnabled` returns `packages !== undefined` — so a workspace with no
member list read as "not a workspace". Adds `packages: ['.']`.

`composite` was already set in `tsconfig.app.json` and `tsconfig.spec.json` but
absent from `tsconfig.base.json`, which is the file the check reads. Adding it
there is declarative for the build, since the leaves already opt in.

Turning the setup on registers `@nx/js:typescript-sync`, which needs `@nx/js`
resolvable from the example, so it is now linked and built alongside the other
local packages.

Knock-on effect worth knowing: the inferred `typecheck` target becomes
`tsc --build --emitDeclarationOnly` instead of `tsc --noEmit -p tsconfig.app.json`
(`noEmit` conflicts with `composite`), and generator prompts now offer `none`
first and default to it. All targets and `nx sync:check` pass.
…elected

`addLint` called `ensurePackage('@nx/eslint')` and destructured
`@nx/eslint/internal` before ever looking at `options.linter`, so
`--linter oxlint` still loaded ESLint's config utilities. In a workspace whose
resolved `@nx/eslint` predates `isTypedLintingEnabled`, that failed outright
with "isTypedLintingEnabled is not a function".

Dispatch through `addLintingToProject` first and only fall through to the
ESLint path when ESLint is actually the chosen linter.

Also stops `@nx/oxlint:init` re-declaring itself in package.json when it is
already there. `addDependenciesToPackageJson` compares versions via
`isIncomingVersionGreater`, which maps any non-semver range to
`UNIDENTIFIED_VERSION` and then returns early with "incoming wins" — so a
`link:`, `file:` or `workspace:` reference was replaced by a plain version
every run. The plugin now only declares itself when absent; the `oxlint` tool
keeps normal bump semantics.

Links `@nx/js` into the react example so its generators resolve from source
rather than the published copy in the repo root.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
Seven generators hardcoded `options.linter ?? 'eslint'`, so a generator invoked
without an explicit linter always set up ESLint — even in a workspace that had
already moved to Oxlint. Six of those seven schemas declare `default: "none"`,
so the code and the schema disagreed too.

Adds `detectLinter`, which reads the workspace's registered inference plugins
and dependencies. Oxlint wins when both are present: a workspace carrying both
has adopted Oxlint alongside ESLint, and new projects should follow the
direction of travel rather than the setup being migrated away from.

Falls back to `eslint` when nothing is detected, so fresh workspaces keep their
historical default. That fallback matters — routing these sites through
`normalizeLinterOption` instead was tried first and changed the default to
`none` in TS solution workspaces, which stopped generating `eslint.config.*`
for five existing specs.
The generated `.oxlintrc.json` carried only `$schema` and an empty `rules`
object, so `nx add @nx/oxlint` produced a config that caught almost nothing.
`oxlint --init` enables the typescript, unicorn and oxc plugins plus the
`correctness` category; there is no reason to scaffold something weaker than
the tool's own.

Verified against oxlint 1.75.0: the generated config now reports real defects
(`no-dupe-keys`, `no-unused-vars`) that the previous one let through.

Also points `$schema` back at `./node_modules/oxlint/configuration_schema.json`,
matching `oxlint --init`. The remote URL it replaced tracked `main`, so it drifted
ahead of the installed version and offered options that version does not have.
The path resolves because this file is only written at the workspace root, where
`oxlint` is a direct dependency — confirmed under pnpm.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
A bare standalone workspace for running `init` generators and inference plugins
against the packages in the working tree rather than a published release. No
projects, no tsconfig, no sources — whatever a generator does to it is the
entire diff.

The root `package.json` deliberately has no `nx` key. Adding one makes the root
a project, and `findProjectForPath` then matches any path — including ones
outside the workspace, since `relative()` yields `../..`. Local `link:`
dependencies get treated as workspace source, so Nx loads a plugin's TypeScript
source instead of its built `dist` and generators fail on NodeNext `.js`
specifiers that only exist after a build. The README says so, so it does not get
added back.

`typescript` is present only to give the lockfile a `packages:` section. A
lockfile whose dependencies are all `link:` has none, and the pnpm parser
iterates it unguarded, taking down the project graph.

Also collapses the per-example plugin excludes in the root `nx.json` to a single
`examples/**` — every example is a standalone workspace, so they are all
excluded from the repo's own tooling. Verified equivalent: example targets and
`nx sync:check` are unchanged.
…format choice

`@nx/eslint:init` appends its editor extension to `.vscode/extensions.json`;
`@nx/oxlint:init` now does the same with `oxc.oxc-vscode`, the official
extension the Oxc docs point at. It only touches the file when it already
exists — setting up a linter is not a reason to start dictating a workspace's
editor config.

Documents the choice between `.oxlintrc.json` and `oxlint.config.ts`, which the
Oxc docs leave neutral. JSON is the better default here: the TypeScript form
needs a Node runtime that executes TypeScript and does not work with the
standalone binary, and only a JSON config has its `extends` chain tracked as
task inputs, since Nx cannot read a TypeScript config statically.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
…e has

`create-nx-workspace` seeds `.vscode/extensions.json`, and generators that
recommend an editor extension only append to it — they skip workspaces without
the file rather than creating one. The fixture had no `.vscode`, so that path
silently did nothing there, which is the opposite of what a fixture for
exercising setup paths should do.

`.vscode` is gitignored repo-wide, so this adds a scoped negation for
`examples/**` only. Force-adding instead would leave an ignored-but-tracked
file and trip the `no-ignored-tracked-files` conformance rule.

Verified: `nx g @nx/oxlint:init` now reports `UPDATE .vscode/extensions.json`
and appends `oxc.oxc-vscode` alongside the existing recommendation.
`--linter oxlint` was accepted by the schemas but ignored by the generators:
each one gated on `=== 'eslint'` (Angular hardcoded it outright, ignoring the
option entirely), so an oxlint request silently produced an ESLint setup, or
none at all.

Each call site now dispatches through `addLintingToProject` before the
ESLint-specific work. That work stays behind an `=== 'eslint'` guard rather
than being generalized, because predefined configs, `extends` entries and
ignore patterns have no cross-linter equivalent — Oxlint ships plugins for
React, Vue and Next, but registers them in `.oxlintrc.json` rather than through
ESLint's config machinery, and has no Angular plugin at all.

Covers angular, cypress, detox, expo, next, node (app + e2e), nuxt, playwright,
plugin, react (app + lib), react-native, remix, vue and web. Angular also gains
a `linter` option on its add-linting schema and threads it from the application
and library generators, which previously dropped it.

Removes guards the dispatch makes unreachable: the duplicated `=== 'none'`
checks in cypress, playwright, detox, expo and react-native, and playwright's
second `!== 'eslint'` bail. TypeScript flagged each as an impossible
comparison.

The Angular CLI migrator (`ng-add`) is deliberately untouched — it converts an
existing ESLint setup, so ESLint is the correct target there.
Generators now pass the Oxlint plugins their framework needs, written as an
`overrides` entry scoped to the project root so a React plugin does not start
linting a Vue project in a mixed workspace.

React/Remix get react, react-perf and jsx-a11y; Next adds nextjs; Vue and Nuxt
get vue. The test-runner plugin is derived from `unitTestRunner`, since Oxlint
names those plugins after the runner.

Two generated files needed fixing to lint clean under the new plugins:
nx-welcome's inline SVGs disable jsx-a11y/prefer-tag-over-role, which maps
`role="img"` to an `<img>` tag that inline SVG has no way to become, and Next's
API route parameter is now `_request`.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
Drops packages/oxlint/eslint.config.mjs and hand-writes the target the plugin
would infer, since the Nx repo infers from published @nx/* packages and
@nx/oxlint is not published yet.

This trades away @nx/dependency-checks and @nx/nx-plugin-checks for this
package — both are JSON-linting rules that Oxlint has no way to run.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
Lets the example exercise generator flows for React, Angular, Vue, Playwright,
Vite and Vitest against local source instead of the published packages.

Note this only covers plugins the CLI loads directly. A plugin reached through
`ensurePackage` from another package — @nx/playwright from @nx/react, say —
still resolves against the repo root, so it needs a root-level link too.
Adds `@nx/web` to the linked plugins, and `--skip-sync` to the postinstall
build: a generated project puts the parent repo out of sync, which failed the
build and took the install down with it.

`@nx/devkit` is pinned to a published version rather than linked. Generators add
it explicitly, and a `link:` loses the version comparison to their `0.0.1`,
which does not exist on npm. The devkit that actually runs is the one resolved
from packages/<plugin>/node_modules either way.
…ride

Framework plugins now land in `<projectRoot>/.oxlintrc.json` rather than an
`overrides` entry in the root config, matching how @nx/eslint gives each project
its own config extending the root.

The generated config extends the root explicitly, because a nested Oxlint config
replaces the root for its subtree rather than merging into it — without the
`extends`, the root's `categories` and `rules` silently stop applying and
findings quietly drop from error to warning.

This also stops one project's generation from busting every other project's lint
cache: the plugin lists the root config as an input for every project, so
rewriting it on each generate invalidated all of them.

Dedupe the inferred target's config inputs, since a project config that extends
the root would otherwise name it twice.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
Nx and Oxlint both discover files through `.gitignore`, so a leading `*` hid
every generated `package.json` from the project graph — the workspace reported
zero projects and no lint targets, while the configs sat there on disk.

Generated scratch is noisy in `git status` again, which is the lesser problem.
The package's main entry now re-exports `createNodes`, so `nx.json` can name the
package directly instead of the `./plugin` subpath, matching @nx/vitest. The
subpath still resolves, and plugin detection accepts either spelling.

Also drops the registration from this repo's own nx.json. `packages/oxlint`
already declares an explicit lint target — the plugin only added a duplicate one
under a different name, and it failed to load whenever the package's dist was
missing.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
Ports the Oxlint tab from the draft docs PR (#34838) onto the
current enforce-module-boundaries page. That PR predates the knowledge
base restructure, so its context no longer applies: the ESLint rule link
has moved into the flat kb/ tree, `knowledge-base/` is gone, and the
Knowledge Base sidebar tab carries no groups. The KB stub and its sidebar
group are dropped for that reason.

Also aligns the introduction page with the other technology intros
(`title: Nx with X`, `weight`, `filter`) and corrects the kb aside that
claimed the rule requires ESLint.

Co-authored-by: Juri <juri.strumpflohner@gmail.com>
`expect(typeof task).toEqual('function')` is guaranteed by the return type —
`runTasksInSerial` always returns a function — yet it was named for verifying
that oxlint gets installed, and was the only case omitting `skipPackageJson`.
It now asserts the devDependencies it was named for, and a companion case
covers the unknown-project error.

The e2e's ESLint discriminator was unreachable: the helper already selects by
`metadata.technologies`, and the fixture workspace installs no `@nx/eslint`.

Adds the first spec for the boundaries bridge. It resolves the rule at module
evaluation out of an optional peer whose CJS/ESM shape it has to guess, so a
shape change surfaced inside Oxlint's `(await import(url)).default` with an
opaque error. Four assertions turn that into a unit failure.
`nx add` takes one package specifier, and there is no `.strict()` on the parser,
so `nx add @nx/oxlint @nx/eslint-plugin` silently forwarded the second package
as generator overrides. It installed only the first, and because
`@nx/eslint-plugin` is an optional peer nothing else pulled it in — so the very
next snippet on that page failed on an unresolvable `@nx/eslint-plugin/nx`.

"Oxlint reads the first config it finds, and only one per directory" reads as
harmless precedence. Two configs in one directory is a hard error, which is what
this package's own `config-file.ts` comment says.

Three more the code disagreed with: a project also gets a task when it owns a
config, even with nothing to lint; `typeAware` and `tsconfig` exist only on the
executor, so the recipe did not work on the inferred tasks the page recommends;
and TypeScript configs cannot have plugins registered for them by generators.

The `oxlint-disable jsx-a11y/prefer-tag-over-role` header explained inline SVGs
in two Next templates that contain no SVG and no `role` — it was copied from the
React welcome template, where it is accurate. It shipped into every generated
Next app.
`ensurePackage` catches `ERR_REQUIRE_ESM` and returns `null`, so on Node below
20.19 / 22.12 loading this ESM package surfaced as `Cannot destructure property
'lintProjectGenerator' of 'null'` from inside `@nx/react` or `@nx/node`, naming
neither ESM nor Oxlint. An `engines` field turns that into one descriptive error
at install time.

`lint-project`'s schema set `additionalProperties: false` without declaring
`plugins`, which the generator's own options type carries and
`addLintingToProject` passes — so the option worked programmatically but was
rejected from the CLI.

Also reverts an incidental `·` to `·` rewrite in the root package.json.
`@nx/oxlint` is ESM, so its entry point imports siblings with a NodeNext `.js`
specifier that only exists after a build. The shared jest resolver hands relative
specifiers to enhanced-resolve as-is, which looks for a `.js` file that is not
there — so any package whose tests reach `@nx/oxlint` through `ensurePackage`
failed with `Cannot find module './src/plugins/plugin.js'`.

That is why `--linter=oxlint` had no unit coverage anywhere: it could not be
loaded from a consumer's test run at all. `packages/oxlint` only worked because
its own jest config carries a `.js`→TS `moduleNameMapper`, which does nothing
for other packages.

The resolver now retries a failed relative `.js` specifier against the
TypeScript source. It only fires after normal resolution fails, so nothing that
resolves today changes.

With that unblocked, adds the coverage the axis was missing: `addLintingToProject`
gets specs for the oxlint and none arms — the helper every framework generator
routes through — and `@nx/node:app` gets a regression test for the gate that
skipped linting entirely under `--linter=oxlint`. Verified the latter fails
against the old gate.
…ng targets

Three fixes from the last round each moved a problem rather than closing it.

`shell: true` was added for the Windows `.cmd` shim, but Node joins the argv
into one string without quoting, so `sh` re-expanded every `lintFilePatterns`
entry — and `sh` has no `globstar`, so `**` collapsed to `*` and files below the
first directory were silently never linted while the task exited 0. Measured:
`apps/a/src/**/*.ts` matched only `apps/a/src/deep/two.ts`. A path containing a
space split into two arguments, and shell metacharacters executed. That reaches
real configs, because `convert-from-eslint` copies `lintFilePatterns` verbatim
from ESLint targets, which conventionally carry `**/*.{ts,tsx}`.

The shell also swallowed the error reporting added beside it: it starts fine and
reports the child's failure as its own exit code, so `result.error` was never
populated and the "Check that oxlint is installed" branch could not fire for the
case it names. Now Windows-only, with a `status === 127` arm for that path.

`lint-project` chose the target name before checking ownership, so an existing
`lint` target that was already ours pushed it onto the free `oxlint` and wrote a
second identical target on a re-run. It now looks for an `@nx/oxlint:lint`
target under any name first, and steps aside from any existing target rather
than only its own executor.

The jest resolver retried by handing the extensionless specifier back to the
resolver, which also accepts `./foo.json` and `./foo/index.ts` — neither of
which NodeNext resolves for `./foo.js`, so a test could pass on an import that
fails in the published package. It now requires an adjacent `.ts`/`.tsx`, and
rethrows the original error rather than one naming a specifier absent from the
source.
…rd sweep

Skipping a not-yet-created `extends` target meant creating it later invalidated
nothing, and neither did edits to it after that. A `{workspaceRoot}` input that
does not exist contributes nothing to the hash — declaring it is exactly how Nx
notices the file appearing — so it is declared again, and only the recursive
walk is skipped.

`resolveTargetName` handed `addPlugin` a single name, collapsing its own
fallback chain; it now passes every remaining candidate, and throws with an
actionable message instead of silently returning a colliding name when all four
are taken.

`addPluginsToOxlintConfig` wrote `.oxlintrc.json` even when the project already
had `.oxlintrc.jsonc`, which Oxlint treats as a hard error rather than an
override. It reuses whichever editable config is already there.

`@nx/node:e2e-project` had the same `undefined`-treated-as-a-linter shape as the
eight sites fixed last round. Low reachability, identical defect class.

Also registers `@nx/oxlint` in `core-plugins.ts` so it appears in `nx list`, and
renames `hasLintableFiles` to `shouldInferTarget` — it is true whenever the
project owns a config, whatever it contains.
…alse contract

The Node range for TypeScript configs said "22.18 or later, or 20.19 or later",
which reduces to >= 20.19. No Node 20.x can execute TypeScript — verified on
20.19.5 and 20.20.2, both fail with ERR_UNKNOWN_FILE_EXTENSION, and
`--experimental-strip-types` is not a recognised option there. The number came
from this package's own `engines` range, which is about `require(esm)`, a
different feature. Oxlint's own hint string carries the same wrong range.

Type-aware linting does not need TypeScript 7 in the workspace: `oxlint-tsgolint`
bundles its own compiler, verified working against typescript 5.9 and with
`node_modules/typescript` deleted entirely. The `tsconfig` option is also not a
type-aware option — Oxlint documents it as overriding the config used for import
resolution, rarely needed.

The boundaries bridge comment claimed Oxlint requires a rule name in `meta`. It
does not — rule names come from the rules object key; it is `meta.name` on the
*plugin* that is required. Its spec's JSDoc also claimed the assertions would
catch an interop shift, which they cannot: jest transforms to CJS while Oxlint
uses Node's ESM loader, and the two diverge on exactly the shape the bridge
guesses. Both now say what they actually cover.

Also drops a hardcoded `npm install` from docs that are otherwise
package-manager-agnostic, and replaces the Angular Storybook coercion's comment
with the real reason it exists — the published `@nx/eslint`'s `LinterType`
predates oxlint, so passing it through does not typecheck yet.
`addLintingToProject` reaches `@nx/oxlint` through `ensurePackage`, the same way
it reaches `@nx/eslint` one branch below, so no static import forms a graph edge
— both packages depend on `@nx/js`, which is why the call is written that way.

Without the edge, `^production` cannot see oxlint's source, and the sandbox
report flagged `js:test` reading ten of its files: the new
`add-linting-to-project` spec asserts on what oxlint's generators produce, so a
change there can change the test result while the cache key stays put.

`eslint` is already listed for exactly this reason. No `dependsOn` goes with it:
jest resolves `@nx/oxlint` to TypeScript source, not `dist`, so there is no build
output to wait on.
The arm added last round matched 127, the POSIX `sh` convention — but it is
gated on `useShell`, which is Windows-only, and Windows runs `cmd.exe`, which
reports command-not-found as 9009. So it was dead on both platforms and the hole
it was written to close stayed open. It now matches both codes.

The two error branches also both advised checking that `oxlint` is installed,
but they fire when the *package manager* cannot be spawned; the message names
that instead.

The specs asserted only `{ success: false }`, which the fallthrough produces for
`status: null` regardless — deleting either branch left them green. They now
assert on `output.error`, and a platform stub covers the Windows paths that were
untestable from a Linux runner: shell on/off, 9009 reported, and an ordinary
exit 1 not mistaken for a missing command.

The `.cmd` rationale was also wrong: `pmc.exec` is a bare `npx`/`pnpm`/`yarn`
name, and libuv's Windows PATH search probes only `.com`/`.exe`, so a direct
spawn was ENOENT well before CVE-2024-27980. That fix made naming a `.cmd`
*explicitly* throw EINVAL — the opposite of what the comment implied.
`lint-project` chose between `lint` and `oxlint` and gave up if both were taken,
logging at `info` and reporting success — so `nx g @nx/react:app --linter=oxlint`
could scaffold an app with no linting and no warning. It now walks the same
candidate list the plugin registration uses, and warns only when every name is
gone.

`addPluginsToOxlintConfig` reused an existing `.oxlintrc.jsonc` — which fixed
writing a second config beside it, but routed the file through `updateJson`,
whose contract is to strip comments. A hand-written `.jsonc` lost the one thing
that format is for. It now declines with a warning, matching how the TypeScript
config case is handled.

`resolveTargetNames`' error told the user to pass `--targetName`, which `init`'s
schema rejects outright; it now names the `nx.json` plugin option that exists.
…lent skip

Making the install command package-manager-agnostic deleted it and left the
sentence promising one, so the block installed only `@nx/oxlint`. Optional peers
are never auto-installed, so a reader following the page registered a bridge
whose rule import cannot resolve. Two `nx add` calls cover it without naming a
package manager.

The bridge reloads the project graph per linted file and reports nothing at all
when no cached graph is available — the task still passes. The page recommends
it, so it now says so.

Also notes that Node 20 cannot execute a TypeScript config whatever Oxlint's own
error message claims, and that type-aware linting ignores `tsconfig` and
discovers the config itself.

Corrects two comments: the plugin's "declared even when absent" rationale
described an invalidation failure that does not occur (the chain walk has no
disk cache, so the file appearing invalidates either way), and the bridge spec
claimed Oxlint throws without `meta.name` when it actually falls back to the
package name and silently renames the rule.
`getProjects` reads the tree, so it never sees lint targets that come from
`@nx/eslint/plugin` inference — the modern default. On such a workspace the loop
matched nothing, added no explicit targets, and reported success. The generator
already guards the typo'd-project case for exactly this reason; the sibling path
with the same outcome had no signal.

It now counts what it converted and warns when that is zero, naming inference as
the likely cause and pointing at the inferred targets the plugin registration
provides instead.
`--linter` was accepted into `BaseArguments` but never read:
`determineLinterOptions` only looked at `interactive`, so the flag was ignored
on every preset that calls it — including `--linter=none`. Its sibling
`determineFormatterOptions` honors `args.formatter`; this one had missed it.

Reading the flag, widening the type to include oxlint, and turning the yes/no
ESLint question into a three-way choice makes `--linter=oxlint` work both
interactively and in CI. The three hardcoded `linter = 'eslint'` fallbacks on
the non-workspaces path respect the flag too, so a requested linter is not
silently replaced.
…e workspaces

The plugin's glob includes `**/package.json`, and `splitConfigFiles` promoted
every matching directory to a project root. Nested marker files — a
`{"sideEffects": false}` next to a bundle, a polyfill directory's stub — have
no `name`, so the whole graph then fails:

    NX   Failed to process project graph.
    The projects in the following directories have no name provided:
      - packages/unenv-preset/src/runtime/polyfill

Filter package.json files through the package manager's workspaces globs, the
same guard Nx core's own package-json plugin applies. A package.json sitting
next to a project.json is still a project regardless of the globs.

When no workspaces are declared the filter is skipped entirely: the patterns
come back empty and the matcher would reject everything, including the root
package.json of a standalone repo that is itself the project.

Verified against this repo: the 148-project graph is identical either way.
The `validate-example` target runs `pnpm install --ignore-scripts && pnpm
validate` in each registered example. `examples/empty` was the only one of the
15 without a `validate` script, so the target failed there as soon as the
example was added.

The workspace has no projects yet — it exists for running generators by hand —
so run-many matches nothing and exits 0. It still builds the project graph,
which is what actually needs proving here: that the linked packages, including
the ESM `@nx/oxlint`, load in a real workspace.
`@nx/oxlint`'s index exported the plugin, the executor, all three generators
and `OXLINT_CONFIG_FILENAMES`. The newer plugin packages — `@nx/vitest`,
`@nx/rsbuild`, `@nx/docker` — keep `.` to the graph plugin alone and put
generators behind `./generators`. Follow them.

- `.` is the plugin only, which is what `nx.json` names.
- New `./generators` subpath carries the three generators.
- Drops the executor from the public surface. It is resolved through
  executors.json; no other plugin exports its executor either.
- Drops `OXLINT_CONFIG_FILENAMES`. Every use is inside the package via the
  relative path, so the export had no consumers.
- Removes the `./plugin` subpath and its entry file. Nothing registered it,
  and `.` already serves that role, so the detection helpers no longer match
  a subpath that cannot resolve.

`@nx/js` reaches the generators with the two-step form already used for
`@nx/vitest/generators`: `ensurePackage` by package name, then require the
subpath. That require makes the js -> oxlint edge visible to
enforce-module-boundaries, which correctly reports the cycle oxlint's
dependency on `@nx/js` creates, so the pair joins the intentional-cycle list
alongside the other ensurePackage peers. The task graph stays acyclic.

The package is unpublished, so none of this is a breaking change.
`@nx/oxlint:test` read all five files under `tools/eslint-rules/` without
declaring them, so Nx Cloud flagged the task as unsandboxed and its cache
results were unreliable.

The boundaries bridge imports `@nx/eslint-plugin/nx`, and that module's
`workspaceRules` export is an IIFE evaluated at load time: it `loadTsFile`s
`tools/eslint-rules/index.ts` and every rule beside it. Importing the plugin
for one rule therefore compiles the workspace's custom rules as a side effect.

`tools/eslint-rules` is its own project (`eslint-rules`), and the test inputs
already include `^production`, so declaring the implicit dependency covers the
reads and keeps the relationship visible to `affected`. It matches how this
project already declares `eslint-plugin`, and `eslint-rules` depends on no
workspace project, so no cycle.
…s lint rules

The bridge pulled `enforce-module-boundaries` out of the `@nx/eslint-plugin/nx`
plugin entry. That entry spreads `workspaceRules`, whose module-level
initializer `loadTsFile`s the consuming workspace's whole `tools/eslint-rules`
directory — so asking for one rule compiled every custom rule in the workspace.

In this repo that showed up as `@nx/oxlint:test` reading five files under
`tools/eslint-rules/` with no declared input, which Nx Cloud flagged as
unsandboxed. Declaring them as an input would have recorded an accident: a unit
test for an Oxlint bridge has no business depending on the repo's own lint
rules.

Adds a semi-private `@nx/eslint-plugin/internal` entry that re-exports the rule
directly, and points the bridge at it. Verified by logging from
`tools/eslint-rules/index.ts`: two loads before, none after, with the bridge
spec still passing. Dropping the plugin entry also removes the CJS/ESM interop
dance the barrel needed.

Replaces the implicit `eslint-rules` dependency added in 77b07f6, which
covered the reads but overstated the relationship — it also pulled the rules
into the `lint` hash via `^default`.
Eight dispatch sites cast `options.linter` to `any` when handing it to
`addLintingToProject`. The casts are not needed: a string enum member is
assignable to its own literal type, so `Linter | LinterType` flows into
`@nx/js`'s wider `LinterType` on its own.

Verified by removing them and building, testing and linting all eight
packages. This does not depend on the enum removal in #36509.
@FrozenPandaz FrozenPandaz changed the title feat(oxlint): add the @nx/oxlint plugin feat(linter): add the @nx/oxlint plugin Jul 29, 2026
…ported Node

`@nx/eslint-plugin/internal` re-exported the rule with `export { default as x }
from`, which TS emits under `importHelpers` as a `defineProperty` getter around
`tslib.__importDefault(...)`. `cjs-module-lexer` cannot see through that call,
so the name never reaches the ESM facade Node builds for a CJS module, and the
ESM `@nx/oxlint` bridge failed to import it:

    node 20.19.5  SyntaxError: Named export 'enforceModuleBoundaries' not found
    node 24.10.0  SyntaxError
    node 24.11.0  SyntaxError
    node 24.14.0  loads
    node 26.3.0   loads

`@nx/oxlint` declares `^20.19.0 || ^22.12.0 || >=24.0.0`, so the bridge was
broken across most of its own support range. `mise.toml` pins 26.3.0, which is
why CI, e2e and three separate manual checks all missed it.

Assigning to a `const` emits a plain `exports.x =` that the lexer sees. The new
spec pins the emit shape rather than the import: an end-to-end load test would
run on the pinned Node where the broken shape works.
…e.json

The filter fell back to `() => true` whenever `patterns.positive` was empty, on
the premise that this meant a standalone repo whose root package.json is the
project. It also means a `project.json`-based integrated monorepo: the globs are
empty whenever the root package.json declares no `workspaces` and there is no
pnpm-workspace.yaml or lerna.json, and `getGlobPatternsFromPackageManagerWorkspaces`
returns `[]` from its own catch when the root package.json cannot be read.

In that shape every nested package.json was admitted again, so a nameless
bundler marker still became a project root and still failed the graph with
ProjectsWithNoNameError -- the crash the filter was added to prevent. The
existing regression test could not catch it because its fixture declares
`workspaces`.

Keeps only the root package.json in that case, which is what the fallback was
protecting; a package.json beside a project.json is admitted by the existing
projectJsonRoots clause. The comment claimed Nx core applies the same filter --
core builds the matcher unconditionally and adds the root only when it carries
an `nx` key, so the fallback inverted core's rule rather than matching it.
`maybeAddOxlintTarget` returned a boolean, so "nothing happened" collapsed three
different causes. Two consequences, both reproduced:

A project whose target name was taken by something other than `@nx/oxlint:lint`
was skipped in silence. With a second project converting, the aggregate warning
never fired, the generator reported success, and the user's hand-written target
was left to run in place of linting.

Any re-run printed "no project has an explicit @nx/eslint:lint target" while the
tree plainly contained one, because `converted` is 0 the second time through.

Returns an outcome instead: a taken name warns naming the project and the flag
to use, an existing `@nx/oxlint:lint` target stays silent as idempotence, and
the aggregate warning is now keyed on whether any project had an ESLint target
at all. The schema description said "projects that have an ESLint target" when
only explicit `@nx/eslint:lint` targets are matched.
`boundaries-plugin/index.ts` still said Oxlint requires `meta.name` on the
plugin. It does not -- without it Oxlint falls back to the package name and the
rule registers as `@nx/oxlint/enforce-module-boundaries`, not the id the docs
tell users to configure. A prior round corrected this claim in the spec and left
the copy here, so the two said opposite things.

The spec header described the `export default` / `module.exports` shape "the
bridge has to guess". That guess was deleted when the bridge moved to a named
import; what these specs genuinely miss is the export names crossing the CJS/ESM
boundary, which the emit-shape spec now pins.

`add-linting-to-project.ts` said `nx-ignore-next-line` keeps the edge out of the
graph to avoid a cycle. `implicitDependencies` adds that same edge, which is why
the pair is listed in `ignoredCircularDependencies`. The ignore is there so
`@nx/dependency-checks` does not demand a package.json entry for a dependency
loaded on demand.
…s configured

The caveat that the rule reports nothing and still passes without a cached
project graph was only on the Oxlint technology page. The features page carries
the Oxlint tab, the `jsPlugins` registration and the `depConstraints` snippet --
it is where a reader configuring the rule lands, and its only caution was about
the plugin API's semver. The kb page had none either.

Adds the aside there and a clause on the kb note, and records that the skip
notice is not a lint diagnostic, so `--deny-warnings` and `--max-warnings` do
not catch it (both flags verified against `oxlint --help`). The technology
page's copy gains the same sentence so the two cannot drift.

Also corrects the convert-from-eslint scope claim -- only explicit
`@nx/eslint:lint` targets are converted, not "every project that has an ESLint
task" -- and gives the `nx add @nx/eslint-plugin` command the prose asked for
without showing.
…cycle

The dirty-source e2e inferred failure from the absence of nx's success string,
which stops catching a linter that reports a violation and still exits 0 if that
message is ever reworded. It also never exercised a cache hit alongside a
failure.

One test now walks the whole cycle: clean source passes, an unchanged re-run is
served from the cache, a violation fails with Oxlint's own diagnostic and exit
code 1, and a fix passes again. Step four deliberately uses different clean
source from step one -- reusing it would be a cache hit, proving only that the
cache replays a success.

Asserts `runCLI.lastExitCode` rather than letting `runCLI` throw: without
`silenceError` it logs the output as an error first, so a passing test would
print a red block into CI logs, and the diagnostic would need a try/catch to
reach. Also pins that the inferred target declares no outputs -- Oxlint reports
to stdout and has no output-file flag.

Verified by running e2e-oxlint locally: 4 passed.
…er it

The docs call `@nx/oxlint` experimental; the two places a user actually picks it
did not. `create-nx-workspace` listed it unlabelled beside ESLint, so the prompt
presented three equals, and the package description npm shows said nothing.

The prompt choice keeps `oxlint` as its value and only changes what the list
displays, matching how the style choices in the same file are written.
The previous fix stopped nested marker package.json files being promoted, but
kept an escape hatch admitting the root package.json when the workspaces globs
came back empty. That reopened the same failure class one level up.

Empty globs mean Nx core creates no root project: core appends the root to the
globs only when it carries an `nx` key, which `nx init` writes. So a root that
genuinely is a project already arrives through the matcher, and the escape hatch
only fired where Nx has no root project at all. Reproduced against real nx with
the built plugin, in an integrated project.json monorepo with a root `src/`:

    root package.json without a name -> NX  Failed to process project graph.
                                           The projects in the following
                                           directories have no name provided:
                                             - .
    same fixture, plugin unregistered -> graph fine
    root package.json with a name     -> a phantom root project appears that
                                         `nx show projects` does not otherwise
                                         list

Applies the matcher unconditionally, as core does.

`should point a root project at ./src` was pinning the defect: its fixture is a
root package.json with no `nx` key, i.e. one Nx creates no project for. Given
the key, it describes a real standalone workspace and passes through the matcher
branch. Adds a test for the integrated layout it could not cover.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants