We provided a CLI tool to help you set up your project, or migrate from the legacy config to the new flat config with one command.
For an existing template that already has this config setup, please refer to the roblox-ts template repository. This includes all necessarily files and configurations to get you up and running.
npx @isentinel/eslint-config@latestIf you prefer to set up manually:
pnpm i -D eslint @isentinel/eslint-configWith "type": "module" in
package.json (recommended):
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel();@isentinel/eslint-config/eslint is an explicit alias of the root export, for
symmetry with @isentinel/eslint-config/oxlint:
// eslint.config.ts
import { isentinel } from "@isentinel/eslint-config/eslint";
export default isentinel();If you want to use eslint.config.ts instead of .js, install jiti v2.0.0 or
greater:
pnpm i -D jiti@^2.0.0See ESLint's TypeScript configuration documentation for more details.
Combined with legacy config:
If you still use some configs from the legacy eslintrc format, you can use the
@eslint/eslintrc package to
convert them to the flat config.
// eslint.config.ts
import { FlatCompat } from "@eslint/eslintrc";
import isentinel from "@isentinel/eslint-config";
const compat = new FlatCompat();
export default isentinel(
{
ignores: [],
},
// Legacy config
...compat.config({
extends: [
"eslint:recommended",
// Other extends...
],
}),
// Other flat configs...
);Note that
.eslintignoreno longer works in Flat config. Use theignoresoption instead, see customization for more details.
The package ships an isentinel-lint bin that runs oxlint and ESLint together.
The starter wizard adds these for you; to wire them up manually:
{
"scripts": {
"lint": "isentinel-lint",
"lint:fix": "isentinel-lint --fix"
}
}See isentinel-lint for what it does and every flag.
Many of the rules in this config are designed to work with the following options set:
{
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}Alternatively, you can install @isentinel/tsconfig from pnpm:
pnpm install --save-dev @isentinel/tsconfigAnd use it in your tsconfig.json:
{
"extends": "@isentinel/tsconfig/roblox"
}The ts/no-non-null-assertion rule is enabled by default, which will warn you
when you use the ! operator to assert that a value is not undefined. The
caveat is that this rule will not always play nicely with
noUncheckedIndexedAccess, and will often require you to disable it in certain
places. I believe that this is a good trade-off, as it will help you catch
potential bugs in your code, but you can disable it if you find it too
restrictive.
{
"rules": {
"ts/no-non-null-assertion": "off"
}
}Install VS Code ESLint extension
Add the following settings to your .vscode/settings.json:
{
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*fmt/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"typescript",
"typescriptreact",
"markdown",
"json",
"jsonc",
"yaml",
"toml"
]
}The package ships a second bin, isentinel-lint, a hybrid runner that drives
oxlint and ESLint together. Point
your lint script at it and forget the wiring (the starter wizard does this for
you):
{
"scripts": {
"lint": "isentinel-lint",
"lint:fix": "isentinel-lint --fix"
}
}It resolves the eslint and oxlint binaries from your project's
node_modules, so both must be installed locally.
By default (a local run, no --type-aware, no --fix) it runs three
children concurrently via
concurrently, grouping their
output:
- oxlint — the full oxlint pass (including its type-aware rules).
- fast pass — ESLint with only the syntactic (non-type-aware) rules, over every lintable file (TS/JS, JSON, YAML, TOML, Markdown, Lua). This is where non-TS files are linted.
- type-aware pass — ESLint with only the type-aware rules, over the TS/JS
family. The preset's config splits exactly in two, so
fast ∪ type-awarereproduces the full config rule-for-rule.
The type-aware pass is the expensive one (it builds a TypeScript program), so the runner skips it entirely when nothing type-relevant changed since the last run — it uses the TypeScript builder to find the files whose type-aware results could have changed, and if that set is empty it prints a short note to stderr and does not start the pass. The fast pass and oxlint always run.
Other behaviours:
-
Sizes each ESLint pass's
--concurrencyfrom how many files it will actually re-lint (its own dirty count), rather than eagerly spinning up a worker per CPU. The fast pass is cheap per file, so it packs far more files per worker (seeFAST_FILES_PER_WORKER) than the type-aware pass. -
Keeps a separate ESLint cache per pass so they never invalidate one another:
.eslintcache-fast-<key>(fast pass),.eslintcache-typeaware-<key>(type-aware pass), and.eslintcache-<key>(the full single-pass config used by--fix, CI and--type-aware=full). A change to an ESLint/oxlint/Prettier/tsconfig file or a lockfile clears each cache that is actually older than that change; a change to the rootpackage.jsonresolution surface (exports,imports,main,module,types,typesVersions,dependencies,devDependencies,peerDependencies) clears only the type-aware caches (a syntactic lint cannot be affected by resolution). Add.eslintcache*to.gitignore— a bare.eslintcacheentry does not match the suffixed names. -
Splits every cache by config variant. ESLint stores a config hash per cache entry, so two runs that resolve even slightly different configs wipe each other's entries when they share one cache file — an agent session and a human session alternating against one cache re-lint the whole project in both directions, every time. The
<key>above is an 8-character hash of the inputs that make this preset resolve a different config: agent session, editor session and CI. Each variant gets its own cache (and its own builder /package.json-hash state), so nothing is invalidated; the variants simply stop overwriting each other. Git-hook runs deliberately share the plain no-agent variant.If your own
eslint.config.*branches on something the runner cannot see — your own agent/editor check, a feature flag, or an explicitisAgent,isInEditorordefaultSeverityoption — setISENTINEL_LINT_CACHE_KEYto a value naming that branch. It is folded into the key, giving those configs separate caches too. -
Runs
--fixsequentially (oxlint --fix, theneslint --fix) so two writers never race on the same files. -
Lets every child run to completion and returns non-zero if any failed — an ordinary lint error in one tool no longer kills the others mid-run.
Unused eslint-disable directives. The default two-pass mode does not report unused
eslint-disabledirectives: a directive naming a rule that lives in the other pass would look unused to the pass that runs it and produce a false positive.--fix, CI and--type-aware=fullruns use the single full config and still report unused directives.
Running both engines only makes sense when the ESLint config is in hybrid
mode (oxlint: true), which drops every oxlint-covered rule from the ESLint
side so the two engines never overlap. A config that omits oxlint: true runs
every mapped rule in both engines — duplicate diagnostics, and under --fix a
tug-of-war as each rewrites toward its own option resolution. So whenever both
engines would run (default mode or --fix), the runner checks whether the
resolved config is hybrid; if it is not, it runs ESLint only, skips oxlint,
and prints one stderr warning (enable hybrid mode, or pass --oxlint to run
oxlint explicitly). The check is cheap: the factory records the hybrid status in
node_modules/.cache/isentinel-lint/ on every config evaluation, and the runner
trusts that cache unless your ESLint config is newer, in which case it probes
eslint --print-config once and caches the result. If the status cannot be
determined it fails open and runs both engines as before. Explicit --eslint /
--oxlint runs skip the check entirely.
Any trailing positional arguments are treated as paths to lint (default .).
| Flag | Description |
|---|---|
--eslint |
Run only ESLint. |
--oxlint |
Run only oxlint. |
--fix |
Apply fixes: oxlint --fix then eslint --fix (sequential). |
--agents, --no-agents |
Force agent-friendly output from both linters on or off (default: on in an agent session). |
--type-aware=off|only|full |
Force a single ESLint pass: off fast-only, only type-aware-only, full the whole config in one pass. Cannot mix with --fix. |
--no-oxlint-type-aware |
Skip oxlint's type-aware rules (no oxlint-tsgolint needed). |
--no-cache |
Disable ESLint's on-disk cache. |
--concurrency <n|off> |
Override the concurrency heuristic with a fixed worker count. |
--eslint-args "<args>" |
Extra arguments forwarded verbatim to ESLint. |
--oxlint-args "<args>" |
Extra arguments forwarded verbatim to oxlint. |
--print |
Print the composed commands without running them. |
-- <args> |
Forward args to the single selected tool (needs --eslint/--oxlint). |
-h, --help |
Show help. |
-v, --version |
Show the version. |
| Variable | Effect |
|---|---|
FILES_PER_WORKER |
Target files a single type-aware ESLint worker handles before adding another (default 350). |
FAST_FILES_PER_WORKER |
Same, for the fast (syntactic) pass, which packs far more files per worker (default 800). |
LINT_MAX_WORKERS |
Upper bound on ESLint workers (default a quarter of available CPUs). |
ESLINT_TYPE_AWARE |
off or only; the type-aware mode --type-aware sets for the ESLint child (false is a legacy alias for off). |
CI |
When set, the runner uses the single full pass with --cache-strategy content (see below). |
By default the runner passes --type-aware to oxlint, which needs
oxlint-tsgolint. If it is not
installed the runner errors out rather than silently skipping type-aware rules.
Install it, or pass --no-oxlint-type-aware to run oxlint without them:
pnpm i -D oxlint oxlint-tsgolint--type-aware=off also drops oxlint's type-aware pass, since the fast mode
skips type-aware linting entirely.
When a CI environment variable is set the runner skips the two-pass split and
runs a single full-config ESLint pass (alongside oxlint). CI cores are few, so
the two-pass parallelism does not pay off, and the full config is the only one
that reports unused eslint-disable directives — which you want enforced in CI.
The pass also switches to --cache-strategy content so caches key on file
contents rather than timestamps, which are unreliable across fresh checkouts.
--agents emits machine-readable output for AI agents: oxlint runs with
--format agent, and ESLint uses the formatter shipped at
@isentinel/eslint-config/formatter-agents, which the runner resolves and
passes to eslint --format for you.
It is on by default whenever an AI coding agent session is detected, so agents
get agent-shaped output without passing the flag. Detection follows
std-env's agent table (Claude Code, Codex,
Cursor, Gemini CLI, opencode, replit, auggie, goose, junie, devin, kiro, pi),
plus the AI_AGENT variable as an explicit override. Git hook and lint-staged
runs are excluded — those are human-initiated. Pass --no-agents to force human
output, or set AI_AGENT to force detection on.
The incremental machinery favours speed, and a few edges are deliberately left to self-heal or need a one-off nudge:
- Config changes reached through an imported module are invisible to a
skipped typed pass. The runner busts caches on the mtime of your
eslint.config.*, tsconfigs and lockfiles, but not on files those configsimport. ESLint's own per-entry config hash would still catch such a change once the pass runs — but if the typed pass was auto-skipped (nothing type-relevant looked dirty), it never runs to notice. Touch youreslint.config.*(or run once with--no-cache) after editing a module it imports. - mtime granularity. Cache freshness is compared by modification time, so
the usual coarse-filesystem-timestamp race that affects ESLint's own cache
applies here too (edits within the same clock tick as the last run can be
missed). CI uses
--cache-strategy contentto sidestep it; locally, a second run settles it. - Solution-style / project-reference tsconfigs. The TypeScript builder used to find type-affected files does not follow project references, so in a solution-style setup builder invalidation silently becomes a no-op and the runner falls back to plain mtime dirtiness. Type-aware results stay correct (ESLint still lints the dirty files); only the cross-file "an imported type changed" invalidation is skipped.
- Hybrid status tracks
eslint.config.*mtimes only. If you toggle hybrid mode (oxlint: true) from a module the config imports rather than the config file itself, the runner trusts its cached hybrid decision for one more run before the factory's passive write corrects it — it self-heals on the next invocation.
Normally you only need to import the isentinel preset:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel();And that's it! Or you can configure each integration individually, for example:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
// `.eslintignore` is no longer supported in Flat config, use `ignores`
// instead
ignores: [
"./fixtures",
// ...globs
],
// Type of the project. `package` for packages, the default is `game`
type: "package",
// Disable yaml support
yaml: false,
});The isentinel factory function also accepts any number of arbitrary custom
config overrides:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel(
{
// Configures for this config
},
// From the second arguments they are ESLint Flat Configs
// you can have multiple configs
{
files: ["**/*.ts"],
rules: {},
},
{
rules: {},
},
);Check out the configs and factory for more details.
Thanks to antfu/eslint-config and sxzz/eslint-config for the inspiration and reference.
By default, this config auto-detects whether ESLint is running in an editor environment (VS Code, JetBrains IDEs, Vim, Neovim) and adjusts certain rules accordingly. In editor mode:
unused-imports/no-unused-importsis downgraded to "warn" instead of "error"- Auto-fix is disabled for certain rules to prevent unwanted changes while editing
- Other rules are adjusted for a better development experience
You can explicitly control this behavior using the ESLINT_IN_EDITOR
environment variable:
# Force editor mode (useful for AI agents or hooks)
ESLINT_IN_EDITOR=true eslint --fix
# Force non-editor mode
ESLINT_IN_EDITOR=false eslint --fixThis is particularly useful when running ESLint from CLI tools or AI agents that make multiple changes, as it prevents imports from being removed before the code that uses them is written.
Alternatively, you can set it explicitly in your config:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
isInEditor: true, // or false
});Enable namedConfigs to require all config items to have a name property.
This improves debugging and makes the
ESLint Config Inspector much more
useful by displaying meaningful names instead of anonymous configs.
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel(
{
name: "project/root",
namedConfigs: true,
},
{
name: "project/custom-rules",
files: ["**/*.ts"],
rules: {},
},
);Note: This will become the default in a future major version.
Since flat config requires us to explicitly provide the plugin names (instead of the mandatory convention from npm package name), we renamed some plugins to make the overall scope more consistent and easier to write.
| New Prefix | Original Prefix | Source Plugin |
|---|---|---|
import/* |
i/* |
eslint-plugin-i |
node/* |
n/* |
eslint-plugin-n |
yaml/* |
yml/* |
eslint-plugin-yml |
ts/* |
@typescript-eslint/* |
@typescript-eslint/eslint-plugin |
style/* |
@stylistic/* |
@stylistic/eslint-plugin |
When you want to override rules, or disable them inline, you need to update to the new prefix:
-// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+// eslint-disable-next-line ts/consistent-type-definitions
type foo = { bar: 2 }Certain rules would only be enabled in specific files, for example, ts/* rules
would only be enabled in .ts files and vue/* rules would only be enabled in
.vue files. If you want to override the rules, you need to specify the file
extension:
// eslint.config.js
import antfu from "@antfu/eslint-config";
export default antfu(
{
typescript: true,
vue: true,
},
{
// Remember to specify the file glob here, otherwise it might cause the
// vue plugin to handle non-vue files
files: ["**/*.vue"],
rules: {
"vue/operator-linebreak": ["error", "before"],
},
},
{
// Without `files`, they are general rules for all files (Markdown
// excluded — see note below)
rules: {
"style/semi": ["error", "never"],
},
},
);Note
Rule overrides without an explicit files constraint are> automatically
excluded from Markdown files, via
composer.setDefaultIgnores.
This prevents JS-only rules (e.g. no-irregular-whitespace,
perfectionist/sort-imports) from crashing on @eslint/markdown's
SourceCode, which doesn't expose JS-specific methods like
getAllComments(). If you want a rule to apply to Markdown, scope it
explicitly with files: ['**/*.md'].
We also provided the overrides options in each integration to make it easier:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
typescript: {
overrides: {
"ts/consistent-type-definitions": ["error", "interface"],
},
},
yaml: {
overrides: {
// ...
},
},
});Overrides that re-state what the preset already resolves to by default fail to typecheck, so you learn immediately (in-editor, no lint run needed) when a preset update makes one of your overrides redundant:
export default isentinel({
rules: {
// Type error: 'no-alert' already defaults to this value in the preset;
// remove the override, or set `redundancyCheck: false` to disable this
// check
"no-alert": "error",
},
});The check understands severity aliases (2 ≙ "error"), option tuples (flagged
only on an exact match), flat-config merge semantics (a bare severity keeps the
previous options, so it is redundant whenever the severity matches), and the
type/roblox variants — a rule that only defaults to "error" for
type: "package" is not flagged in a game config. It applies to the top-level
rules, every per-integration overrides, and un-scoped extra configs; both
the ESLint and oxlint factories are covered.
Compared defaults are the canonical (CI, non-editor) ones: editor/agent
downgrades, defaultSeverity promotion and oxlint hybrid rule-dropping are
intentionally ignored. files-scoped configs and composer .override() calls
are not checked. Opt out entirely with redundancyCheck: false, or per line
with // @ts-expect-error.
This config includes the CSpell plugin by default, which
will warn you when you have misspelled words in your code. This can be useful
for catching typos, and ensuring that your code is consistent. Roblox keywords
are also included in the dictionary, which is provided by the
cspell-dicts-roblox
package. If any words roblox words are missing, please open an issue on that
repository rather than this one.
Sometimes you will have words that are not in the dictionary, but are still
valid for your project. To add these words to the dictionary, you can create a
cspell.config.yaml file in the root of your project with the following
content:
# cspell.config.yaml
words:
- isentinel
- isverycoolTo disable this, you can set the spellCheck option to false:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
spellCheck: false,
});For more information on how to configure the spell checker, please refer to the CSpell documentation.
This plugin
eslint-plugin-perfectionist
allows you to sort object keys, imports, etc, with auto-fix.
The plugin is installed and most rules are enabled by default, but these rules can be disabled or overridden by your own config.
We provide some optional configs for specific use cases, that we don't include their dependencies by default.
To enable React support, you need to explicitly turn it on:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
react: true,
});To enable Jest support, you need to explicitly turn it on:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
test: true,
});Running npx eslint should prompt you to install the required dependencies,
otherwise, you can install them manually:
pnpm i -D eslint-plugin-react-x eslint-plugin-react-jsx eslint-plugin-react-naming-convention eslint-plugin-jestEnable the opinionated flawless/naming-convention rules with naming: true,
or pass an object to add new selector entries or override the built-in defaults:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
naming: {
selectors: [
// Override the default variable format
{ format: ["snake_case"], selector: "variable" },
// Add a new selector not covered by the defaults
{ format: ["PascalCase"], prefix: ["I"], selector: "interface" },
],
// Applied only to .tsx files, before `selectors`
selectorsTsx: [{ format: ["StrictPascalCase"], selector: "function" }],
},
});Entries in selectors apply to both the TS and TSX configs; selectorsTsx
applies to the TSX config only. User entries are prepended before the defaults,
and the rule applies the first matching entry after sorting by specificity — so
an entry with the same specificity as a default overrides it, while new
selectors are simply added. A less specific entry cannot override a more
specific default. The overridesTypeAware escape hatch still replaces the whole
rule if you need full control.
The config can run alongside (or be replaced by)
oxlint in three modes: ESLint-only
(the default), hybrid (oxlint && eslint), and oxlint standalone via the
@isentinel/eslint-config/oxlint export.
// eslint.config.ts - hybrid mode: ESLint drops every rule oxlint covers
import isentinel from "@isentinel/eslint-config";
export default isentinel({
oxlint: true,
});// oxlint.config.ts
import { isentinel } from "@isentinel/eslint-config/oxlint";
export default isentinel({
name: "project/options",
});Requires the optional peer dependencies:
pnpm i -D oxlint oxlint-tsgolintSee docs/oxlint.md for the per-rule mapping, what stays in ESLint and why, and migration notes.
On large projects the type-aware rules dominate ESLint wall time; an explicit
numeric --concurrency is the biggest single win. The typeAware option
additionally splits the config into two complementary passes: a non-type-aware
pass (typeAware: false) that never builds a TypeScript program and a
type-aware-only pass (typeAware: "only"). Together they cover exactly the full
config. See docs/type-aware-split.md.
If you're developing an ESLint plugin, you can enable specialized rules to help ensure your plugin follows best practices:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
eslintPlugin: true,
});Running npx eslint should prompt you to install the required dependencies,
otherwise, you can install them manually:
pnpm i -D eslint-plugin-eslint-pluginIf you want to apply lint and auto-fix before every commit, use
hk. Create hk.pkl in your project root:
amends "package://github.com/jdx/hk/releases/download/v1.48.0/hk@1.48.0#/Config.pkl"
hooks {
["pre-commit"] {
fix = true
steps {
["eslint"] {
glob = List("*")
check = "eslint --no-warn-ignored {{files}}"
fix = "eslint --fix --no-warn-ignored {{files}}"
}
}
}
}and then install hk and activate the hooks:
mise use hk pkl # or see https://hk.jdx.dev for other install methods
hk installThere is a visual tool to help you view what rules are enabled in your project and apply them to what files, eslint-config-inspector
Go to your project root that contains eslint.config.ts and run:
npx eslint-config-inspectorThis project follows Semantic Versioning for releases. However, since this is just a config and involves opinions and many moving parts, we don't treat rules changes as breaking changes.
- Node.js version requirement changes
- Huge refactors that might break the config
- Plugins made major changes that might break the config
- Changes that might affect most of the codebases
- Enable/disable rules and plugins (that might become stricter)
- Rules options changes
- Version bumps of dependencies
Sure, you can configure and override rules locally in your project to fit your needs. If that still does not work for you, you can always fork this repo and maintain your own. I am open to PRs that help improve the overall experience for developers, and there may still be rules activated that do not apply to the roblox-ts ecosystem.