feat(linter): add the @nx/oxlint plugin - #36491
Draft
FrozenPandaz wants to merge 56 commits into
Draft
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. |
Contributor
|
View your CI Pipeline Execution ↗ for commit 0a17340
❌ Strict sandboxing stopped this CI run. View violations ↗ ☁️ Nx Cloud last updated this comment at |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
FrozenPandaz
force-pushed
the
feature/nxc-4312-add-oxlint-plugin
branch
2 times, most recently
from
July 28, 2026 22:57
651e173 to
d210b9f
Compare
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.
…workspace" This reverts commit d32e44b.
…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.
This was referenced Jul 29, 2026
…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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Current Behavior
@nx/oxlintlives 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:
LinterTypeis'eslint' | 'none', andnx g @nx/react:library --linter oxlintfails schema validation.Expected Behavior
Graduates the plugin into
packages/oxlint, shipped experimental, and widens the generator linter axis so--linter oxlintworks.The plugin
Inference plugin, lint executor, and
init/lint-project/convert-from-eslintgenerators, mirroringpackages/eslint. It is the first ESM package in the repo, so every load path was verified rather than assumed:tsgo,module: nodenextloadResolvedNxPluginAsync→await handleImport()createNodestuple resolvesgetImplementationFactory→require()require(esm); Nx already doesmodule.default ?? module(await import(url)).default.js→TSmoduleNameMapperCaveat for review:
require(esm)is unflagged only on Node ≥20.19 / ≥22.12. Below that, generators and executors would fail withERR_REQUIRE_ESM. Nx declares noengines, so if that band is still supported this package wants one.Defects fixed relative to the labs source
@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-sourcecondition and would have broken once published. Now uses the public@nx/eslint-plugin/nxexport.addPlugincame from the removed@nx/devkit/src/utils/add-plugindeep path.--experimental-nested-config, removed in Oxlint v0.16.0. Nested config is the default;--disable-nested-configand--type-awareare exposed instead.convert-from-eslintdiscarded the install callback, so Oxlint was written topackage.jsonbut never installed.nxVersionresolved to the literal0.0.1.^1.55.0→^1.75.0;oxlint.config.mtsadded to the recognized config filenames.lint, withaddPluginfalling through tooxlintwhen ESLint owns it.The inference plugin is rebuilt on the
@nx/eslintpatterns: a lintable-file check so non-JS projects stop getting targets, content-aware cache keys viacalculateHashesForCreateNodes+PluginCache, tsconfig-chain inputs for type-aware linting, and recursiveextendsresolution — Oxlint only tracks that chain for its LSP, so Nx has to walk it for caching to be correct.The linter axis
LinterTypegainsoxlintand moves canonically to@nx/js, the lowest package every linter consumer already depends on;@nx/eslintre-exports it. All 41 generator schemas that offer a linter acceptoxlint, and the interactive prompt offers it.A new
addLintingToProjecthelper in@nx/jsdispatches to the right plugin viaensurePackage(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.detectLinterreplaces seven hardcoded?? 'eslint'fallbacks, so a generator invoked without an explicit linter follows the workspace. Oxlint wins in a hybrid workspace; the fallback stayseslintwhen nothing is detected, preserving the historical default.Dogfooding
examples/react/basicswitches to Oxlint, andpackages/oxlintlints itself (alongside ESLint, which keeps thepackage.jsonrules 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
@nxare each rejected at config-parse time, andcreate()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 testsnx conformance— clean across 9 rulesnx run e2e-oxlint:e2e-local— 4/4build-base— 43 projectsThe e2e suite took three rounds: the first two versions passed while asserting against ESLint's target, because
@nx/js:libsets up ESLint by default. It now identifies the Oxlint target bymetadata.technologies, generates fixtures with--linter=none, and asserts Oxlint's ownno-debuggerdiagnostic plus task failure.Known gaps
=== 'eslint'and skip Oxlint; being converted separately.convert-from-eslintadds targets alongside ESLint but does not translate rules — wrapping@oxlint/migrateis a follow-up.@nx/vite,@nx/vitestand@nx/remixcalladdIgnoresToLintConfigfrom@nx/eslint/internal; these silently no-op in an Oxlint workspace.@nx/oxlintis linked at the root asworkspace:*rather than a pinned version, since it has never been published.Related Issue(s)
Fixes NXC-4312
View session information ↗