npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@isentinel/eslint-config

v6.3.0

Published

iSentinel's ESLint config

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

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

  • 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. --print shows the chosen workers, files per worker and heap on stderr. If a child still runs out of memory, the runner prints a hint that names LINT_TYPED_HEAP_MB. A size set in LINT_TYPED_HEAP_MB also caps the workers at budget ÷ size. An explicit LINT_MAX_WORKERS or --concurrency is never reduced. A --max-old-space-size already in NODE_OPTIONS wins 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=full and 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 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.

  • Treats --fix as a check followed by a fix, in three steps. oxlint --fix goes 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-aware checks whichever passes that mode selects instead (full the single full pass, only the type-aware pass alone, off the 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 --fix costs 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-cache leaves 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-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. CI and --type-aware=full runs use the single full config and still report them.

A local --fix inherits 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 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

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

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

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

pnpm'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-config

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