@isentinel/eslint-config supports
oxlint, a high-performance Rust
linter, in three consumption modes:
- ESLint only (default) — nothing changes for existing consumers.
- Hybrid — oxlint runs the rules it covers (natively, via oxlint-tsgolint for type-aware rules, or by running the original ESLint plugins inside oxlint as jsPlugins); ESLint runs the rest.
- Oxlint standalone — an oxlint-only config via the
@isentinel/eslint-config/oxlintexport.
Oxlint support requires the optional peer dependencies:
pnpm i -D oxlint oxlint-tsgolintEnable oxlint: true in your ESLint config and create an oxlint config using
the same options:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
oxlint: true,
type: "game",
});// oxlint.config.ts
import { isentinel } from "@isentinel/eslint-config/oxlint";
export default isentinel({
name: "project/options",
type: "game",
});[!NOTE] In hybrid mode, give the oxlint factory the same structural and plugin options you gave ESLint (
type,roblox,stylistic,root,react,test, ignores...). The react and jest families run in oxlint as jsPlugins and vitest runs as native oxlint rules (bar two rules with no native port), while ESLint keeps only their type-aware rules, so mirroring the options avoids both coverage loss and double-linting. e18e is handled automatically (both factories enable it for non-roblox projects).
Then run both linters:
[!IMPORTANT] The type-aware
ts/*rules are executed by oxlint-tsgolint. The factory enablesoptions.typeAwarein the generated config by default (whenoxlint-tsgolintis resolvable), so no--type-awareCLI flag is needed. Ifoxlint-tsgolintis not installed, the ESLint factory keeps these rules in ESLint instead of dropping them (and warns at config-build time), so they run in exactly one engine either way.Residual edge: if you have
oxlint-tsgolintinstalled but explicitly setoptions: { typeAware: false }in your oxlint config, those rules run in neither engine — the ESLint factory cannot see your oxlint config, so it still drops them. DisablingtypeAwarein hybrid mode is on you. Likewise, when ESLint and oxlint run from different working directories (per-package monorepo linting), their twooxlint-tsgolintresolutions can disagree; keep the peer resolvable from every lint cwd (or setoptions.typeAwareexplicitly) to avoid double-reporting or one-sided coverage.
The split is driven by one internal resolver backed by generated native and
jsPlugin capability metadata. oxlintRuleMapping, exported from
@isentinel/eslint-config/oxlint, remains as a generated compatibility and
audit view:
| Target | Meaning |
|---|---|
native |
Implemented natively in oxlint (Rust) |
tsgolint |
Type-aware rule executed by oxlint-tsgolint (typeAware) |
js-plugin |
The original ESLint plugin runs inside oxlint as a jsPlugin |
| stays in ESLint | Not in the mapping; documented in staysInEslint |
A rule you name in options.rules is resolved in this order: an alternate
native equivalent, an explicit ESLint-only exception, an implementation
preference, a generated native capability, then a safe generated jsPlugin
capability. Unknown custom rules retain the standalone fallback behavior.
Examples include ts/no-shadow → no-shadow, jest/valid-title →
jest-js/valid-title, and unmapped native rules such as no-inner-declarations
staying native. Preferring the native rule wherever one exists is what keeps a
categories entry overridable: categories only ever enable native rules, so an
entry routed to a jsPlugin instead would configure a different rule and leave
the native one on its defaults.
Jest and React prefer their jsPlugins; Vitest and Unicorn prefer native
implementations. id-length, no-loop-func, and unicorn/no-lonely-if are
exact jsPlugin preferences, the last because its JS implementation currently has
better autofix parity. Whenever a preferred jsPlugin also has a native
implementation, the generated config disables the native rule at the same scope
so categories cannot activate a duplicate. This suppression remains in
native-only mode even though the JS rule itself stays in ESLint.
The generated capability data in src/generated/ is rule names only: which
rules oxlint implements natively (from oxlint --rules), which rules the
installed plugins expose, and which of each need type information. Everything
else a rule carries — categories, fix status, documentation links — lives in
src/oxlint/typegen.d.ts as JSDoc, where an editor can show it, rather than
shipping as runtime data. Oxlint names, plugin aliases and package specifiers
are derived from the rule name at lookup time. pnpm gen refreshes all of it,
so a dependency upgrade produces a reviewable diff of what changed hands.
A preset-only "off" entry does not load a jsPlugin merely because a capability
exists. It is still emitted when the rule is native: categories can only ever
switch native rules back on, so a rule the preset deliberately disables has to
reach the config as an explicit "off" or opting into a category would silently
revive it. Explicit user disables remain authoritative in both cases.
Fragments passed as extra arguments are not translated — their rule names reach Oxlint verbatim, which is the escape hatch when naming an Oxlint rule directly.
With oxlint: true, the ESLint factory drops every rule the resolver hands to
oxlint — not every rule in the exported view, which is a sample of the preset
and would leave a rule enabled in both engines if it missed one. Everything else
keeps running in ESLint, notably:
- JSON / YAML / TOML / Markdown / package.json / pnpm rules — oxlint only lints JS/TS files.
- Type-aware plugin rules — any rule whose
meta.docs.requiresTypeCheckingis true (sonar/no-ignored-return,sonar/no-redundant-optional,sonar/no-try-promise,unicorn/no-non-function-verb-prefix,eslint-plugin/no-property-in-node, fourjest/*rules). The oxlint factory refuses to emit them as jsPlugins because they crash or silently no-op without type information; a test enforces this against the plugins' runtime metadata. - Functionally type-aware rules — the type-aware React rules below do not
declare
requiresTypeCheckingbut still need type information, so they are excluded manually. The jest family runs in oxlint via its real ESLint plugin as a jsPlugin under thejest-jsprefix (oxlint reserves the nativejestprefix; jest honorssettings.jest.globalPackage = @rbxts/jest-globals); the vitest family runs as native oxlint rules, exceptvitest/padding-around-allandvitest/prefer-vi-mocked(no native port) which run via@vitest/eslint-pluginas a jsPlugin. - Type-aware custom rules —
roblox/*type-aware rules,sentinel/explicit-size-check,flawless/prefer-read-only-props,flawless/naming-conventionandnaming; oxlint jsPlugins have no type information. - Type-aware React rules —
react/no-implicit-children,react/no-implicit-key,react/no-implicit-ref,react/no-leaked-conditional-renderingandreact/no-unused-propsneed type information but do not declarerequiresTypeChecking, so they are excluded manually. The rest of the react family (react-x, react-jsx, react-naming-convention, the flawless react syntax rules) runs in oxlint as jsPlugins. eslint-comments/*— these linteslint-disabledirectives;oxlint-comments/*covers the oxlint directives on the oxlint side.- Markdown code blocks — rules and formatting for fenced code blocks stay in ESLint (oxlint cannot lint virtual files).
A test suite asserts that every rule dropped from ESLint in hybrid mode is enabled in the oxlint factory output, so coverage loss is a test failure.
Traffic also flows the other way: oxlint's native oxc/* rules and, under
react: true, its react-perf/* rules have no ESLint counterpart in the
preset, so they run in oxlint alone.
The four react-perf rules — jsx-no-new-object-as-prop,
jsx-no-new-array-as-prop, jsx-no-new-function-as-prop and
jsx-no-jsx-as-prop — flag values built inline in JSX, which get a fresh
identity every render and defeat React.memo and hook-dependency bailouts. They
are enabled as errors. With roblox: true (the default) they are configured
with nativeAllowList: "all", which skips host tags:
<textbutton Event={{ … }} /> is idiomatic Roblox UI with no reasonable way
out. Under roblox: false the rules apply to host elements too.
The rules match object literals, array literals, function expressions and JSX —
not call expressions, so <frame Size={UDim2.fromScale(1, 1)} /> is not
reported even though it churns the same way.
oxlint: "native" narrows the hand-off: oxlint runs only the rules it
implements in Rust (native rules plus the tsgolint type-aware ones). Everything
a jsPlugin would have run — spelling, sonar/*, the non-native unicorn/*,
perfectionist/*, the react and jest families, the custom roblox/* and
flawless/* rules and oxfmt formatting — stays in ESLint, where the original
plugins already run.
oxlint-comments is the one jsPlugin that stays loaded. Native-only does not
remove oxlint-disable comments, only narrows which rules they suppress, so the
directives still need linting — and ESLint cannot take the plugin over, because
its rules use oxlint's createOnce API (ESLint rejects a rule without
create). Keeping it is close to free: its rules visit Program once per file,
and the cost that native-only mode exists to avoid is the volume of jsPlugin
rules, not the JS bridge itself. Measured on this repo (src test scripts, best
of 5, with tsgolint): 2139 ms with no jsPlugins, 2367 ms with oxlint-comments,
14426 ms with the full jsPlugin set.
Both sides must agree, or rules run twice (or nowhere):
// eslint.config.ts
export default isentinel({ oxlint: "native", type: "game" });// oxlint.config.ts
export default isentinel({
name: "project/options",
jsPlugins: false,
type: "game",
});With isentinel-lint this yields three passes, each with a distinct job:
| Pass | Engine | Runs |
|---|---|---|
oxc |
oxlint (+ tsgolint) | native Rust rules, type-aware ts/* |
fast |
ESLint, no types | everything else that needs no type information |
typed |
ESLint, type-aware | the remaining type-aware rules (roblox/*, flawless/naming-convention, the type-aware react and sonar rules) |
Trade-offs versus full hybrid: the jsPlugin rules run under ESLint's slower runtime again, but they run under their real plugin (no oxlint jsPlugin scope or metadata caveats), and formatting no longer goes through the oxfmt jsPlugin.
Rules you configure in your own oxlint.config.ts move with them: once
jsPlugins: false is set, an entry naming a plugin that is no longer loaded
("sonar/*", say) is dropped from the generated config, since oxlint rejects
the whole build over a rule whose plugin is not registered. Put those entries in
eslint.config.ts, which is where the rules now run. JsPlugins you register
yourself are untouched, and so are their rules — but the entry's name is what
the rule prefix is keyed on, so register with the exported resolver rather than
a bare specifier, or the rules silently go with the drop:
import {
isentinel,
resolveJsPluginSpecifier,
} from "@isentinel/eslint-config/oxlint";
export default isentinel(
{ name: "project/options", jsPlugins: false },
{
name: "local/my-plugin",
files: ["**/*.ts"],
jsPlugins: [
{ name: "my", specifier: resolveJsPluginSpecifier("eslint-plugin-my") },
],
rules: { "my/some-rule": "error" },
},
);Because the ESLint side drops every oxlint-owned rule (and lets oxlint format
real JS/TS files), any rule in your own config that oxlint owns has no effect
in ESLint. Setting "oxfmt/oxfmt": "off" in a scoped ESLint block, for example,
does not stop oxlint from formatting the file.
In hybrid mode the factory warns at config-build time and lists each such entry
(rule name and config block), telling you to move it to oxlint.config.ts (or
your oxfmt options) or remove it. Entries scoped to Markdown virtual files
(**/*.md/**) are exempt, since oxlint cannot lint them. Suppress the warning
with oxlintWarnDeadRules: false.
The check treats every config not named isentinel/* as yours, so a third-party
shareable config you compose in can trigger the warning too (its oxlint-owned
entries are genuinely inert in hybrid mode). Suppress it with
oxlintWarnDeadRules: false if you cannot edit that config.
The overrides on a config option applies at the scope that option already
covers, and outranks the preset there — whether it enables or disables, and
whether or not the preset ships the rule at all:
export default isentinel({
name: "project/options",
test: {
vitest: {
files: ["**/*.spec.ts"],
overrides: {
"vitest/no-importing-vitest-globals": "off",
// Not enabled by the preset; on for spec files only.
"vitest/require-hook": "error",
},
},
},
type: "package",
});Two things still bound what an override can do:
- Position. An override sits where its config option sits in the pipeline,
so a later module that names the same rule wins. Disabling an
oxc/*rule fromtypescript.overridesis undone by theoxcmodule that follows it; use the top-levelrules, which merges after every preset module. - Reachability. A few rules cannot run in oxlint: those needing type
information no jsPlugin can supply, those whose plugin is not installed, and —
under
jsPlugins: false— every jsPlugin rule. The factory warns and lists them at config-build time rather than discarding them quietly; the ESLint side still runs them. Suppress withwarnDroppedOverrides: false.
Use only oxlint (no ESLint) via the dedicated export:
// oxlint.config.ts
import { isentinel } from "@isentinel/eslint-config/oxlint";
export default isentinel({
name: "project/options",
test: { jest: true },
type: "game",
});Standalone mode keeps spell checking (CSpell), directive-comment hygiene
(oxlint-comments), formatting (oxfmt) and the custom roblox-ts rules by
running the ESLint plugins as jsPlugins. Note the limitations above: JSON, YAML,
TOML, Markdown, pnpm and the type-aware custom rules are only available through
ESLint.
The factory passes oxlint's config-level categories, options, env and
globals through to the generated config:
// oxlint.config.ts
import { isentinel } from "@isentinel/eslint-config/oxlint";
export default isentinel({
name: "project/options",
categories: { nursery: "warn" },
env: { browser: false },
globals: { $: "readonly", window: "off" },
options: { maxWarnings: 10 },
type: "game",
});-
categories— enables oxlint rule categories (correctness,nursery,pedantic,perf,restriction,style,suspicious). The preset enables every rule it wants explicitly, so all seven categories default to"off"— otherwise oxlint's own defaults (notablycorrectness: "warn") would fire on top of the curated set and report rules the preset deliberately leaves out. Values are merged over that default, so enabling one category leaves the rest off. Useful for previewing rules the preset does not enable yet, but expect reports the preset has not vetted. A category rule is silenced or reconfigured fromoptions.rulesunder its oxlint name — see How rules are split for how a name resolves.A category only reaches plugins registered at the top level: oxlint applies categories to the base plugin set, not to plugins an override adds. The preset keeps a plugin at the top level when it asks for it across the whole tree (
typescript,unicorn,import,jsdoc,promise,oxc, plusreact-perfunderreact: true), so categories reach those project-wide. File-scoped ones —vitestunder the test globs,nodeover the complement of therobloxglobs — are registered on their own override instead, which keeps them from firing outside those globs; inside them they contribute the rules the preset curates, and categories add nothing further.To opt a plugin into your categories project-wide, register it yourself on a whole-tree config fragment:
isentinel( { categories: { correctness: "error" } }, { name: "project/native-plugins", files: [GLOB_SRC], plugins: ["jsx-a11y"], rules: {}, }, );
-
options— top-level linter options (typeAware,typeCheck,maxWarnings,denyWarnings,reportUnusedDisableDirectives,respectEslintDisableDirectives). The factory emitstypeAware: trueby default wheneveroxlint-tsgolintis resolvable (oxlint errors whentypeAwareis enabled without it), so type-aware rules run without any CLI flag; user-provided values win. CLI flags take precedence over configoptionswhen both are set. -
env— enables or disables predefined environment globals (browser,node,es2021...). Emitted at the top level. -
globals— declares project globals as"readonly","writable"or"off". Values are emitted at the top level and as a trailing override for source files, so they win over the globals the preset declares (browser, node and ES2021). Note that oxlint does not let"off"remove globals that come fromenvor the ES builtins — it only removes globals declared at an outer config level.
In hybrid mode, // eslint-disable-next-line <rule> comments for rules that
moved to oxlint become unused directives (ESLint reports them) and must be
converted to // oxlint-disable-next-line <rule> using the oxlint-side rule
name:
- Core rules keep their name (
no-console). ts/<rule>becomestypescript/<rule>.- Rules that run as jsPlugins use the aliased prefix from the mapping
(
unicorn/*that is not native becomesunicorn-js/*, core rules that are not native becomeeslint-js/*).
The official @oxlint/migrate
tool can convert comments in bulk with --replace-eslint-comments.
Linting a Markdown file occasionally (roughly 1 in 15 runs) crashes node with a
segmentation fault (exit 139 / 0xC0000005). This is independent of oxlint and
hybrid mode — it reproduces identically with the published ESLint-only config
(6.0.0-beta.6) and disappears when oxfmt/oxfmt is disabled for **/*.md —
the crash is in eslint-plugin-oxfmt's synckit worker invoking the oxfmt native
binding on Markdown files (the oxfmt CLI and the in-process format() API do
not crash on the same input). Until fixed upstream, rerun the lint on a crash;
disabling Markdown formatting (formatters: { markdown: false }) avoids it at
the cost of unformatted Markdown.
If you are on ESLint 10, @typescript-eslint/utils and
@typescript-eslint/type-utils must resolve to >=8.62.1 or ESLint crashes at
startup (Class extends value undefined). This repo pins them via pnpm
overrides, which do not travel with the published package — add the same
overrides to your own pnpm-workspace.yaml if you hit that error:
overrides:
"@typescript-eslint/type-utils": 8.62.1
"@typescript-eslint/utils": 8.62.1The repo ships scripts/validate-oxlint.ts, which checks every native rule in
the generated config against the installed oxlint binary (oxlint --rules):
pnpm validate:oxlint