Skip to content

christopher-buss/eslint-config

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2,016 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@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@latest

Manual Install

If you prefer to set up manually:

pnpm i -D eslint @isentinel/eslint-config

Create 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.0

See 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 .eslintignore no longer works in Flat config. Use the ignores option 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/tsconfig

And 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, 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-aware reproduces 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 --concurrency from 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 (see FAST_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 root package.json resolution 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 .eslintcache entry 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 explicit isAgent, isInEditor or defaultSeverity option — set ISENTINEL_LINT_CACHE_KEY to a value naming that branch. It is folded into the key, giving those configs separate caches too.

  • Runs --fix sequentially (oxlint --fix, then eslint --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-disable directives: 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=full runs use the single full config and still report unused directives.

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 .).

Flags

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.

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).
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 configs import. 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 your eslint.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 content to 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-imports is 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 --fix

This 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 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: {
			// ...
		},
	},
});

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
  - isverycool

To 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-jest

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.

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-tsgolint

See docs/oxlint.md for the per-rule mapping, what stays in ESLint and why, and migration notes.

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-plugin

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/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 install

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-inspector

Versioning 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.

About

iSentinel's ESLint config preset for TypeScript and roblox-ts

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages