@isentinel/eslint-config
v6.3.0
Published
iSentinel's ESLint config
Maintainers
Readme
@isentinel/eslint-config
Usage
Starter Wizard
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.
Example Usage
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@latestManual Install
If you prefer to set up manually:
pnpm i -D eslint @isentinel/eslint-configCreate config file
With "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();Optional: TypeScript Config Support
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.
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.
Add script for package.json
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.
Recommended Settings
TSConfig
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"
}ESLint
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"
}
}VS Code support (auto fix)
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"
]
}isentinel-lint
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.
What it does
By default (a local run, no --type-aware, no --fix) it runs three
children concurrently via
concurrently's core (no shell),
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.Sizes the heap of every ESLint child that builds a TypeScript program (the type-aware pass, the full pass and the fix child). Each worker holds its own program, so the limit that matters is workers × heap. By default the runner splits half the machine's memory (or its cgroup limit, when lower) across those workers, and raises Node's heap limit when the share is larger. When the budget cannot give each worker twice Node's default heap, it runs fewer workers: every worker loads the program of nearly every project, so fewer, larger workers need far less memory in total than more, smaller ones. The flag goes in the child's
NODE_OPTIONS, which every ESLint worker inherits; oxlint and the fast pass never get it.--printshows the chosen workers, files per worker and heap on stderr. If a child still runs out of memory, the runner prints a hint that namesLINT_TYPED_HEAP_MB. A size set inLINT_TYPED_HEAP_MBalso caps the workers at budget ÷ size. An explicitLINT_MAX_WORKERSor--concurrencyis never reduced. A--max-old-space-sizealready inNODE_OPTIONSwins over all of this: the runner adds nothing and keeps the worker count.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 config, used by CI,--type-aware=fulland the fix child below). 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.Treats
--fixas a check followed by a fix, in three steps.oxlint --fixgoes first and alone, since it rewrites the files the ESLint children are about to read. The run's ESLint passes then run as ordinary checks — concurrent, own caches, no writes. By default those are the fast and type-aware passes — the single full pass in CI; an explicit--type-awarechecks whichever passes that mode selects instead (fullthe single full pass,onlythe type-aware pass alone,offthe fast pass alone). Finally one ESLint child applies fixes to only the files those checks had messages about, using the full config (a superset of every pass, so it can fix anything any of them found).When the checks report nothing, which is the common case, that last child never spawns and
--fixcosts what a check run costs. Being the run's only ESLint writer, it also cannot race anything.The file list comes from the check passes' own caches: ESLint stores each file's result beside its cache entry and replays it on a cache hit, so a warm cache is that pass's whole-tree verdict — including for a pass that auto-skipped, whose verdict stands precisely because nothing it cares about changed.
--no-cacheleaves no verdict to read, so the fix child falls back to linting everything it was given. A target outside the working directory is invisible to the listing the verdict is read against, so it is handed to the fix child as it stands, alongside the reported files; that child runs without the cache, since a stale entry for a file no verdict covers would let ESLint skip it.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. CI and--type-aware=fullruns use the single full config and still report them.A local
--fixinherits this from its checks. The fix child runs the full config and would report an unused directive, but it only ever sees the files the checks reported, and a file whose only problem is an unused directive is not one of them — so those directives survive a local fix run. In CI the check is the full config, so they are reported and removed as before.
Hybrid detection
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 .).
Large file selections
A git hook that hands the runner thousands of files should not put them on a
command line: cmd.exe stops at 8,191 characters and CreateProcessW at
32,767, so tools end up splitting the list into batches that each start another
lint stack. Pass the list as data instead:
git diff --cached --name-only -z --relative --diff-filter=ACMR | isentinel-lint --files0-from ---files0-from <file|-> reads NUL-terminated UTF-8 paths from a file, or from
stdin for - (the git -z / find -print0 shape). Relative entries resolve
against the working directory, exactly like positional paths (hence --relative
above when running from a sub-package). The paths go through the same planning,
caching and type-aware skip as positional ones, in one run. An empty list lints
nothing and exits 0 — it never falls back to .. A list that is not valid UTF-8
or holds an empty entry is refused, as is combining the flag with positional
paths.
The runner never puts paths on a command line itself either: every oxlint and
ESLint child is launched as node <bootstrap> <engine bin> with no shell, and
its full argument list is streamed to it over stdin. As a consequence ESLint's
own --stdin / --stdin-filename cannot be forwarded through --eslint-args
or --.
Flags
| Flag | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| --eslint | Run only ESLint. |
| --oxlint | Run only oxlint. |
| --fix | Check as usual, then fix only the files the checks reported (one child). |
| --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. |
| --files0-from <file\|-> | Read the paths to lint from a NUL-delimited UTF-8 list (- for stdin); an empty list exits 0. |
| --print | Print the composed commands without running them. |
| --no-oxfmt | Skip the oxfmt pass that runs when the project has an oxfmt.config.* file (see Standalone oxfmt). |
| -- <args> | Forward args to the single selected tool (needs --eslint/--oxlint). |
| -h, --help | Show help. |
| -v, --version | Show the version. |
Environment
| 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). |
| LINT_TYPED_HEAP_MB | Heap per worker for the program-building ESLint children: auto (default), a size in MB, or off (Node's default). |
| 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). |
Type-aware oxlint and tsgolint
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.
CI
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.
Agent output
--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.
Known limitations
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.
Customization
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.
Editor Mode
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
});Named Configs
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.
Plugins Renaming
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 }Rules Overrides
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
filesconstraint are> automatically excluded from Markdown files, viacomposer.setDefaultIgnores. This prevents JS-only rules (e.g.no-irregular-whitespace,perfectionist/sort-imports) from crashing on@eslint/markdown'sSourceCode, which doesn't expose JS-specific methods likegetAllComments(). If you want a rule to apply to Markdown, scope it explicitly withfiles: ['**/*.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: {
// ...
},
},
});Redundant Override Detection
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.
Spell Checker
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.
perfectionist (sorting)
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.
Optional Configs
We provide some optional configs for specific use cases, that we don't include their dependencies by default.
React
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,
});Jest
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-jestAnti-Slop
Enable eslint-plugin-flawless rules that catch patterns AI-written code tends
to produce (object-typed parameters, unknown returns, unsafe dictionary types,
conditional empty-object spreads, Reflect access) with antiSlop: true:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
antiSlop: true,
});Naming Conventions
Enable 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.
Roblox names
strictCamelCase and StrictPascalCase reject two capitals in a row, so a
CFrame has to be stored in targetCframe and the identifier stops matching
the type it holds. allowedWords lets listed words keep their own casing inside
a name, while the rest of the name is still checked. It is off by default:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
naming: {
// Use ROBLOX_ALLOWED_WORDS, generated from `@rbxts/types`
allowedWords: true,
},
});const targetCFrame = new CFrame(); // ok
const listLayoutUIPadding = 0; // ok
const autoZIndex = 0; // ok - properties count, not just types
const target_CFrame = 0; // still an error
const fooXYBar = 0; // still an error - not a Roblox name
const colorToHSV = 0; // still an error - methods do not count, use `colorToHsv`The generated list covers type names and data properties, not methods. A call
spells the API out at the call site, where the naming rules do not reach, so
Color3.ToHSV is no reason to let the variable holding its result skip them.
Pass an array to use exactly those words instead. Spread ROBLOX_ALLOWED_WORDS
to extend the list rather than replace it:
import isentinel, { ROBLOX_ALLOWED_WORDS } from "@isentinel/eslint-config";
export default isentinel({
naming: {
allowedWords: [...ROBLOX_ALLOWED_WORDS, "HTTP", "URL"],
},
});The list reaches every selector, including your own selectors entries. A
selector opts out with its own allowedWords: []. Only the two strict formats
honour it, so it can never loosen snake_case or UPPER_CASE, and a word only
matches at a hump boundary, so it cannot split an existing hump.
At the start of a strictCamelCase name a word also matches with its first
character lowercased, since that is the only spelling the format allows there —
motor6DWeld and targetMotor6DPart both pass. Everywhere else the word has to
be spelled as the API spells it, so targetmotor6DPart is still an error.
Oxlint
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.
Standalone oxfmt
By default, oxfmt runs inside the linter as the oxfmt/oxfmt rule. To run it as
its own tool (oxfmt, editor format-on-save through the oxc extension), set
formatter: "standalone" in both factories and add an oxfmt.config.ts built
from the preset:
// oxfmt.config.ts
import { isentinelOxfmt } from "@isentinel/eslint-config/oxfmt";
export default isentinelOxfmt({ cwd: import.meta.dirname, printWidth: 100 });// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
formatter: "standalone",
});isentinelOxfmt starts from the same options the linter uses (preset defaults,
then .editorconfig, then .prettierrc). Each key you pass replaces its base
value, except ignorePatterns and overrides, which are appended to the
preset's. The override order is the one the oxfmt/oxfmt rule uses: the
preset's Markdown, YAML and JSON5 overrides, then the .editorconfig sections
(without keys you set at the root), then yours, then the .prettierrc
overrides. Override globs without a slash are anchored to the config directory
(*.json becomes ./*.json), because the rule matches them only there. cwd
is the directory to read .prettierrc and .editorconfig from (default
process.cwd()). Pass import.meta.dirname, as the wizard does, so the result
does not depend on where oxfmt runs.
isentinelOxfmt takes formatters and componentExts, with the same meaning
as in the factories, so oxfmt formats the same files the linter would. TOML,
.mdx and other files the linter does not format are ignored.
In standalone mode, oxfmt never sees the linter's options. The factories warn
once per lint run when you set any of these, because they do not change what
oxfmt formats: formatters (any value except true), componentExts,
ignores, or gitignore (false or custom files). They change only which
Markdown code blocks ESLint formats: formatters chooses their languages, and
the ignores choose their files. Set the equivalent in isentinelOxfmt instead
(formatters, componentExts, ignorePatterns, and format options).
stylistic: false turns off the stylistic lint rules but not oxfmt. The rule
widths also come from the oxfmt config only, not from
formatters.prettierOptions or formatters.oxfmtOptions. The setup wizard
copies migrated .eslintignore patterns to both configs.
With formatter: "standalone", the linter enables oxfmt/oxfmt only in
Markdown code blocks (see below), but keeps the rules that turn off formatting
conflicts. Lua formatting (Stylua) stays in ESLint, because oxfmt does not
format Lua. The setup wizard can write all of this for you.
isentinel-lint runs oxfmt --check next to the linters whenever it finds an
oxfmt.config.ts or oxfmt.config.mts, and oxfmt --write last with --fix.
It does this whatever the formatter option is, so set
formatter: "standalone" with such a file, or the files are formatted twice.
Pass --no-oxfmt to skip the pass.
Both factories read the oxfmt config that oxfmt itself uses: .oxfmtrc.json,
.oxfmtrc.jsonc, oxfmt.config.ts or oxfmt.config.mts, in the nearest
directory from the working directory up to the filesystem root. More than one of
them in one directory is an error, as in oxfmt. Its root options (without
ignorePatterns and overrides) override formatters.prettierOptions, and
formatters.oxfmtOptions overrides them.
Nested oxfmt configs (a config file in a subdirectory) are not used, in either
mode. One root config formats the whole project: the oxfmt/oxfmt rule sets
disableNestedConfig, isentinel-lint passes --config, the wizard's format
scripts pass --disable-nested-config, and its VS Code settings set
oxc.fmt.disableNestedConfig. Use overrides for per-folder options. The same
holds for .editorconfig: only the nearest one at or above the root config is
read, so a .editorconfig in a subdirectory does not change formatting.
flawless/arrow-return-style uses two widths. maxLen is your limit: an arrow
becomes implicit only if it fits. It comes from stylistic.maxLen, else
printWidth, and it can be shorter than the formatter width on purpose.
useOxfmt.printWidth is the width the rule gives oxfmt to predict its output.
It always comes from the root printWidth of the oxfmt config, so that --fix
agrees with oxfmt. An override that changes printWidth or tabWidth for JS or
TS files gets a warning, because the linter uses the root values. The comment
length rules take their tabSize from the same oxfmt widths.
Markdown prose and Markdown code blocks are formatted separately, in both modes.
Oxfmt formats the prose at the Markdown width (jsdocPrintWidth, else 80) and
leaves code blocks alone (embeddedLanguageFormatting: "off"). ESLint formats
each code block with oxfmt/oxfmt and the options of its language, the same as
a file of that language: a TypeScript block uses the root printWidth, a YAML
block the YAML options. Overrides and .editorconfig sections, which match by
path, do not reach code blocks; the config's ignorePatterns do. The lint rules
in a code block use the same widths as in a source file. ESLint does not format
code blocks in files it ignores.
The stylistic indent and quotes, and the JSON bracket spacing, follow the
oxfmt options (useTabs, tabWidth, singleQuote, bracketSpacing) unless
you set them in stylistic.
Both modes write the same output, with one known difference: when one import
merges into another (import/no-duplicates), eslint mode sorts the imports
before the merge and standalone mode after it, so comments above the merged
import can end in a different order.
Type-Aware Split
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.
ESLint Plugin Development
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-pluginCo-located Tests
projectStructure requires every source file to sit next to its test file,
through
eslint-plugin-project-structure.
src/place-service.ts then has to be joined by src/place-service.spec.ts.
Only enforceExistence is driven here, so the rule never enforces naming:
// eslint.config.ts
import isentinel from "@isentinel/eslint-config";
export default isentinel({
projectStructure: {
// The plugin's name placeholders, plus `{ext}` for the extension of the
// file that matched. Default: ["{node-name}.spec.{ext}"]
enforceExistence: "__tests__/{node-name}.test.{ext}",
// Default: every source file the preset lints
files: ["src/**/*.{ts,tsx}"],
// Default: tests, `.d.ts` files and build configs
ignores: ["**/index.ts"],
// Default: `process.cwd()`
projectRoot: import.meta.dirname,
// Restricts the check to one subtree. Default: all of `projectRoot`
structureRoot: "src",
},
});pnpm i -D eslint-plugin-project-structure{node-name} matches the kebab-case filenames the preset enforces; the others
are {nodeName}, {NodeName}, {node_name} and {NODE_NAME}. A file already
matching a template is exempt from it. A source file with no test disables the
rule at the source.
The rule needs no type information, so oxlint runs it too: the same option is
accepted by the oxlint factory, and hybrid mode (oxlint: true) hands the check
to oxlint.
Two caveats. Deleting foo.spec.ts does not change foo.ts, so a cached run
replays the old clean result — the check is only sound uncached. And the plugin
writes projectStructure.cache.json into projectRoot while it has something
to report, so gitignore it.
Git hooks
If 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/[email protected]#/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 installpnpm's global virtual store
Only relevant if you have set
enableGlobalVirtualStore: true.
Otherwise pnpm's hoisting hides the problem and there is nothing to do.
With the setting on, a package's real location moves out of your project, so
TypeScript resolves each dependency's imports from its own declared dependencies
alone. Several packages in this preset's tree import types they never declare —
eslint-flat-config-utils imports from eslint without declaring it at all.
skipLibCheck hides the resulting TS2307, but the type still degrades to
any, which surfaces as misleading reports elsewhere:
typescript(no-unsafe-argument): Unsafe argument of type Linter.Config[]
assigned to a parameter of type TypedFlatConfigItem[]Since isentinel() returns a FlatConfigComposer from that package, this hits
your eslint.config.ts directly. Add the companion pnpm plugin, which declares
the missing imports for you:
pnpm add --config @isentinel/pnpm-plugin-eslint-configpnpm auto-loads any config dependency whose name matches its plugin pattern, so
there is nothing to configure. Use the CLI rather than hand-editing
pnpm-workspace.yaml: config dependencies are pinned to an exact version and
verified against an integrity hash that pnpm add --config records for you.
View what rules are enabled
There 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-inspectorVersioning Policy
This 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.
Changes Considered 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
Changes Considered as Non-breaking Changes
- Enable/disable rules and plugins (that might become stricter)
- Rules options changes
- Version bumps of dependencies
I prefer XXX...
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.
