eslint-plugin-unicorn
sindresorhus/eslint-plugin-unicorn/AGENTS.md
Keep rules simple. Target common patterns, skip rare edge cases rather than overcomplicating the rule. These constructs are intentionally unsupported. Do not add machinery to handle them: Rules export a default config object with create and meta. The create function uses context.on(NodeType, listener) to register visitors (this is the Unicorn-specific API, not standard ESLint). See the ESLint custom rules guide for the underlying API. Key differences from standard ESLint: Options are accessed via context.options[0]. Use meta.defaultOptions for defaults (no manual…
What's in it
- Agents
- Philosophy
- Out of scope
- Rule anatomy
- recommended config level
- Helper naming
- Rule languages
- TOML
- Reusable utilities
- Code path analysis
- Auto-generated files
- Documentation
- Testing
- Edge cases to test
- Linting
- Autofix
- Rule naming
- Creating a new rule
- Commit message format
# Agents
## Philosophy
Keep rules simple. Target common patterns, skip rare edge cases rather than overcomplicating the rule.
### Out of scope
These constructs are intentionally unsupported. Do not add machinery to handle them:
- **`Array` subclasses** — Receiver checks treat `new Foo()` as non-array, so `class Foo extends Array {}` is missed. Do not add class heritage resolution for this. (`Error` subclasses are different: `createTypeCheckers` resolves class heritage by default.)
- **Shadowed built-ins** — Receiver checks may recognize built-ins by name without resolving scope. Do not add scope resolution for shadowed constructors.
- **BigInt typed-array chains** — Only direct constructors, static `.from()`/`.of()`, and explicit types are recognized. Do not add flow analysis for method chains.
## Rule anatomy
Rules export a default config object with `create` and `meta`. The `create` function uses `context.on(NodeType, listener)` to register visitors (this is the Unicorn-specific API, not standard ESLint). See the [ESLint custom rules guide](https://eslint.org/docs/latest/extend/custom-rules) for the underlying API.
Key differences from standard ESLint:
- Use `context.on('NodeType', listener)` and `context.onExit('NodeType', listener)` instead of returning a visitor object.
- Listeners return or yield problem objects (`{node, messageId, fix, suggest, data}`) directly. The adapter calls `context.report()` for you.
- Fix functions receive `(fixer, {abort})`. Call `abort()` to bail out of an unfixable case.
```js
const MESSAGE_ID = 'rule-name';
const messages = {
[MESSAGE_ID]: 'Error message with {{placeholder}}.',
};
/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('CallExpression', node => {
return {
node,
messageId: MESSAGE_ID,
data: {placeholder: 'value'},
fix: fixer => fixer.replaceText(node, 'replacement'),
};
});
};
/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce …',
recommended: true, // 'unopinionated' (safest, in both presets), true (in recommended only), or false (opt-in)
},
fixable: 'code', // or omit; add hasSuggestions: true for suggestions
schema: [],
defaultOptions: [{option: 'default'}], // merged automatically
messages,
},
};
export default config;
```
Options are accessed via `context.options[0]`. Use `meta.defaultOptions` for defaults (no manual merging).
### `recommended` config level
`meta.docs.recommended` picks the preset that enables the rule. `'unopinionated'` does NOT mean "too opinionated" — it means the opposite:
- **`'unopinionated'`** — Uncontroversial; in both `unopinionated` and `recommended` (the former is a subset). Safest bucket and the default for new rules.
- **`true`** — A more opinionated call, still on by default. In `recommended` only.
- **`false`** — Off by default, only in `all`. For niche or opt-in rules.
| `recommended` | `unopinionated` | `recommended` config | `all` |
|---|---|---|---|
| `'unopinionated'` | on | on | on |
| `true` | off | on | on |
| `false` (or omitted) | off | off | on |
So a rule too opinionated or niche for broad use is `false`, never `'unopinionated'`. If unsure which level fits, share your recommendation and ask.
Name boolean options in the positive `check*` form (for example, `checkProperties`), never the negated `ignore*`/`skip*` form, so option naming stays consistent across rules. This does not apply to array/pattern options like `ignore` (a list of patterns to ignore), which follow ESLint's own conventions.
### Helper naming
Name helpers after what they return or do:
- `is*`/`has*`/`should*`/`can*`/`needs*` must return booleans. Prefer explicit `false` over `undefined` in predicate helpers.
- `get*Problem` returns one problem object or `undefined`; `get*Problems` returns/yields multiple problem objects.
- `report*` should call `context.report()` directly.
- Avoid `check*` for private helpers. Reserve `check*` for public boolean options, like `checkProperties`.
- Do not combine reporting/yielding with a predicate return. Split into a problem builder and a boolean at the call site.
## Rule languages
Every new rule should declare the official [`meta.languages`](https://eslint.org/docs/latest/extend/custom-rules#rule-languages) field, in `"plugin/language"` form, one per line: `['js/js']` for JavaScript/TypeScript-only rules (most), or the languages it supports (for example `['js/js', 'css/css']`, or `['*']` for any file type).
Available identifiers:
- `js/js` — JavaScript and TypeScript
- `css/css` — [`@eslint/css`](https://github.com/eslint/css)
- `json/json`, `json/jsonc`, `json/json5` — [`@eslint/json`](https://github.com/eslint/json)
- `markdown/commonmark`, `markdown/gfm` — [`@eslint/markdown`](https://github.com/eslint/markdown)
- `html/html` — [`@html-eslint/eslint-plugin`](https://github.com/yeonjuan/html-eslint)
- `toml/toml`: [`eslint-plugin-toml`](https://github.com/ota-meshi/eslint-plugin-toml)
- `yml/yaml` — [`eslint-plugin-yml`](https://github.com/ota-meshi/eslint-plugin-yml)
Most rules visit JavaScript AST nodes, so `js/js` is all they can support. But when a rule's logic is language-agnostic (filename, raw text, comments, or disable directives), support as many languages as is feasible. Root node types differ per language (`Program` for JS/TS, HTML, TOML, and YAML, `StyleSheet` for CSS, `Document` for JSON, `root` for Markdown), so use `onRoot(context, listener)` to run on every root and `getComments(context)` for cross-language comments (both from `rules/utils/`). For reference, see `prefer-https` (`['*']`, raw-text scan) and `no-empty-file` (per-language root handlers).
CSS-only rules (`['css/css']`) belong in [`eslint-cssicorn`](https://github.com/sindresorhus/eslint-cssicorn), not here.
### TOML
- The `Program` body always contains a `TOMLTopLevelTable`, even in empty files. Its `body` contains key/value pairs and table declarations; an empty named table counts as content.
- `TOMLValue` nodes distinguish strings, integers, floats, and dates/times through `kind`. Read numeric source text to preserve precision and signs; skip `inf` and `nan` when grouping digits. Do not convert `1.0` to `1`, which changes the TOML type.
- The parser exposes `#` comments as `Block` nodes. Use `getComments(context)` and preserve `#` when fixing comments. `sourceCode.parserServices?.isTOML` identifies TOML.
- Basic strings and basic quoted keys interpret escapes; literal strings do not. `escape-case` demonstrates escape-preserving edits. `string-content` checks decoded string values only, leaves keys unchanged, and writes double-quoted basic strings. Its serializer uses `JSON.stringify()` plus DEL escaping and skips fixes for unpaired surrogates.
- Use `languages.toml` from `test/utils/test.js` in snapshot cases. For regular rule-tester tests, use `language: languages.toml.language` and `plugins: languages.toml.plugins`. Test fixes and suggestions with the TOML parser, including literal/multiline strings and comments.
## Reusable utilities
`../eslint-node-test` adapts its infrastructure (rule adapter, snapshot test harness, doc generation) from this plugin. When changing shared patterns here (rule anatomy, testing conventions, autofix rules), consider whether the equivalent should be ported over there.
`../eslint-cssicorn` shares CSS rule utilities with this plugin (for example, `rules/shared/css-shorthand-properties.js`). Keep them in sync: when you change a CSS utility in one plugin, make the same change in the other.
Before writing helpers, check these directories:
- **`rules/ast/`** - AST node type checks: `isMethodCall`, `isCallExpression`, `isMemberExpression`, `isFunction`, `isStringLiteral`, etc.
- **`rules/utils/`** - General utilities: `needsSemicolon`, parenthesis helpers (`shouldAddParenthesesToMemberExpressionObject`, etc.), `isNodeMatches`, `getCallExpressionTokens`, `isSameReference`, `getReferences`, etc.
- **`rules/fix/`** - Fixer helpers: `removeArgument`, `removeMethodCall`, `replaceArgument`, `replaceReferenceIdentifier`, `removeParentheses`, `replaceTemplateElement`, `switchCallExpressionToNewExpression`, etc.
- **`rules/shared/`** - Shared rule logic for rules that share patterns (e.g., `simple-array-search-rule.js`).
Import from the barrel `index.js` in each directory (e.g., `import {isMethodCall} from './ast/index.js'`).
Also check `../eslint-node-test/rules/ast/`, `../eslint-node-test/rules/utils/`, and `../eslint-node-test/rules/fix/` — some helpers there were adapted from here and may have picked up fixes or edge cases worth porting back.
When at least two rules need the same non-trivial logic, put it in a shared utility (`rules/utils/` for general helpers, `rules/shared/` for shared rule logic) and use it from each rule instead of duplicating it. This also applies when you write logic that an existing rule already has: extract it from that rule and use it in both. Keep trivial or rule-specific helpers local.
Also use `@eslint-community/eslint-utils` for helpers like `findVariable`, `getStaticValue`, `hasSideEffect`, `getPropertyName`, and token predicates (`isCommaToken`, `isSemicolonToken`, etc.).
Most commonly used utilities:
- **`isMethodCall`** - The most used helper. Check if a node is a method call matching specific criteria.
- **`isParenthesized`**, **`getParenthesizedRange`** (from `rules/utils/`) - Handle extra parentheses around nodes.
- **`needsSemicolon`** - Check if a semicolon is needed before a replacement.
- **`isValueNotUsable`** - Check if a call expression's return value is unused (safe to change behavior).
- **`hasSideEffect`** (from `@eslint-community/eslint-utils`) - Check if a node has side effects, important for safe autofixes.
- **`findVariable`** (from `@eslint-community/eslint-utils`) - Resolve a variable's scope binding.
- **`getStaticValue`** (from `@eslint-community/eslint-utils`) - Get a node's static value at lint time.
- **`fixSpaceAroundKeyword`**, **`removeMethodCall`**, **`removeArgument`** - Common fix helpers.
## Code path analysis
Use [ESLint's code path analysis API](https://eslint.org/docs/latest/extend/code-path-analysis) when a rule needs to know whether control flow always exits a branch or function body (e.g., via `return`, `throw`, `break`, `continue`, exhaustive `switch`, infinite loop). CPA is more accurate than manual AST walking because it handles complex constructs like `try`/`catch`/`finally`, labeled breaks, and unreachable code after infinite loops.
When to use CPA instead of manual AST walking:
- **Checking if an `if` branch always exits** — Use `trackBranchExits` from `rules/utils/`. It registers CPA event listeners and returns a predicate `(branch) => boolean`. Query it after the `IfStatement` has exited (use `context.onExit`). Used by `no-useless-else`, `no-declarations-before-early-exit`, `prefer-else-if`.
- **Checking if a function body always exits** — Track segments per code path using `onCodePathStart`/`onCodePathEnd`/`onCodePathSegmentStart`/`onCodePathSegmentEnd`/`onUnreachableCodePathSegmentStart`/`onUnreachableCodePathSegmentEnd`. Snapshot segment reachability at `BlockStatement:exit` for function bodies (before code path segments end). See `require-proxy-trap-boolean-return` for the pattern.
Key implementation notes:
- Use a **per-code-path segment stack** (`segmentSetStack`) so nested functions don't pollute the enclosing path's state.
- When checking CPA data from a parent node, use `context.onExit` (not `context.on`) so inner code paths have been fully analyzed.
- At `onCodePathEnd`, all segments have already ended, so `currentSegments()` is empty. Snapshot reachability at the AST node exit (e.g., `BlockStatement:exit`) instead.
- `trackBranchExits` uses a `prevSegments`-based check: a branch "falls through" if any post-if merge segment has a `prevSegment` in the branch's terminal segment set.
When NOT to use CPA:
- Simple last-statement checks (e.g., "does the last statement return?") — just check `node.type`.
- Collecting return statements or associating them with functions — a simple function stack or AST walk is fine.
- Fixer logic that only needs to know "return or throw" in the last position — no CPA needed.
## Auto-generated files
- **`rules/index.js`** is auto-generated. Never edit it by hand. Run `npm run create-rules-index-file` to regenerate after adding or removing rules.
- **Non-JavaScript language table** in `readme.md` is generated from `meta.languages`. Run `npm run fix:non-js-languages` after changing language support; add new language columns in `scripts/create-non-js-languages-list.js`.
- **Doc headers** in `docs/rules/<rule>.md` (everything above `<!-- end auto-generated rule header -->`) are auto-generated by `eslint-doc-generator`. Do not edit them. Run `npm run fix:eslint-docs` to update.
On rebase, `rules/index.js` and the `readme.md` rules table almost always conflict because other rules were added meanwhile. Don't hand-resolve `rules/index.js` — take either side, then run `npm run create-rules-index-file`. For `readme.md`, keep both rows and re-sort the table alphabetically.
## Documentation
Keep rule documentation succinct, especially sections about limitations and caveats. Focus on practical information users need to understand and use the rule.
Do not use Markdown tables in rule documentation. Use prose, lists, or code examples instead.
Use JavaScript syntax for configuration examples, not JSON-style quoted keys and strings, unless the example is specifically JSON.
For rule documentation examples, prefer one failing (`// ❌`) example followed by one corresponding passing (`// ✅`) example in each code block whenever possible. Use separate blocks for distinct cases instead of grouping all failing and passing examples together.
## Testing
Tests should be comprehensive with many edge cases, but no duplicate coverage. Add lots of focused edge-case tests for matching and fixes/suggestions. Add tests for edge cases the rule intentionally ignores to document the behavior.
Tests use the built-in `node:test` runner. Prefer `test.snapshot()` which auto-generates snapshots for errors, fixes, and suggestions:
```js
import {getTester} from './utils/test.js';
const {test} = getTester(import.meta);
test.snapshot({
valid: ['validCode'],
invalid: ['invalidCode'],
});
```
Other test modes: `test.typescript()` and `test.vue()` set the parser for all cases in the block. For individual TypeScript cases within a normal `test.snapshot()`, use `{code, parser: parsers.typescript}` instead. Import `parsers` from `./utils/test.js`.
- **Never run integration tests** (`test/integration/test.js`). They are too slow for development.
- **While developing, only run targeted tests**: `node --test test/rule-name.js`. Do not run `npm test` or the full suite until all changes are complete.
- **Only run the full test suite (`npm test`) once at the very end** to confirm everything passes.
- **For new rules, run dogfooding before pushing**: `npm run run-rules-on-codebase`. Re-run it after each fix — fixes to rule logic often surface new violations elsewhere. Delete any scratch files first (e.g. in `.ai-temporary/`), or the dogfooding run lints them too.
- Create or update snapshots: `node --test --test-update-snapshots test/rule-name.js`. A new snapshot case fails until you do this. Snapshots are saved in `test/snapshots/rule-name.js.snapshot`.
- Focus a single case: wrap with `test.only('code')` or `test.only({code, options})`, and run with `node --test --test-only test/rule-name.js` (remove before committing). Never update snapshots while `only` is used: the update rewrites the whole snapshot file, so the snapshots of the skipped cases are deleted.
- Use `t.assert.*` inside tests, with the strict methods (`strictEqual`, `deepStrictEqual`), because `t.assert` is loose mode. Helpers outside tests import `node:assert/strict`.
- For non-snapshot tests, use `test()` with explicit `errors` and `output`
### Edge cases to test
Include test cases for these when relevant to the rule:
- **TypeScript** - Type assertions (`foo as Bar`, `<Bar>foo`), non-null assertions (`foo!`), `satisfies`, generics. Verify both matching and fixer/suggestion output, including optional chaining behavior and ASI protection when the output can start with `(` or `[`. Use `{code, parser: parsers.typescript}`. For rules that use type information (e.g. `isBoolean`, `parserServices.getTypeAtLocation`), test three tiers where it makes sense: plain JS, TS with only type annotations (syntax-only, via `parsers.typescript`), and TS with full type info (via the `typeAware` helper with `projectService: {allowDefaultProject: ['*.ts']}`). See `test/no-unsafe-property-key.js` for the `typeAware` pattern.
- **JSX** - JSX expressions and fragments, if the rule targets patterns that can appear in JSX.
- **Comments** - Inline and block comments inside the targeted node, to verify fixes don't drop them.
- **Parenthesized expressions** - Extra parentheses around the target: `(foo).bar()`.
- **Nested/chained** - The pattern appearing inside other expressions or chained calls.
- **Computed properties** - `obj[method]()` vs `obj.method()`.
- **Tagged templates** - `` tag`string` ``.
- **Optional chaining** - `foo?.bar()`, `foo?.bar?.baz`.
- **Spread** - `[...foo]`, `{...foo}`, `fn(...args)`.
- **Destructuring** - The pattern inside destructuring assignments or parameters.
- **Return value used vs unused** - `const x = foo.bar()` vs `foo.bar()` as a statement. Some fixes are only safe when the return value is unused (see `isValueNotUsable`).
## Linting
CI lints with **ESLint**, not `xo` — a clean `npx xo` run does not mean CI passes.
Run `npm run fix` (or `npm run fix:js` for JS only) — it auto-fixes and reports whatever remains. Prefer it over hand-fixing errors one at a time.
Lint enforces single quotes (`avoidEscape: false`, no template literals). Don't write backtick strings in test cases unless you need interpolation — `--fix` can't convert a backtick string that contains a `'`, so you'd have to fix it by hand.
## Autofix
Always try to provide an autofix if it cannot change runtime behavior. If an autofix could change runtime behavior, try to provide a suggestion instead.
Do not complicate autofixes to handle shadowed built-in globals like `Number`, `Math`, `parseInt`, or `parseFloat`. Treat that as unsupported unless the rule already has a simple, local scope-safety check for another reason.
Do not complicate autofixes to preserve Proxy or accessor side effects. Treat that as unsupported.
Do not block reports, suggestions, or autofixes only to preserve sparse array holes. Treat array holes as unsupported.
Do not turn all of a rule's autofixes into suggestions because of a rare evaluation-order case. These cases are unsupported: temporal dead zone reads, `valueOf`/`toString` coercion order, and when a side-effect-free expression is evaluated. Keep the autofix for the common pure form (for example `new Set([...x])`, `array.some(x => x === value)`, or a pure inline callback). Use a suggestion only when a real side effect can change the result, such as a call, assignment, update, `await`, `yield`, or a callback that is not proven pure. When a fix changes evaluation order, add a test that proves the most common form of the pattern is still autofixed.
When writing fix functions:
1. **Comments** - Keep reporting unless comments affect rule semantics; let ESLint handle disable directives. Withhold edits that remove or move comments using `getCommentSafeProblem()` or `abort()`. Preserve safe token edits.
2. **Parentheses** - Replacing `foo` in `foo.bar()` with a complex expression may need wrapping: `(a + b).bar()`. Use helpers from `rules/utils/` like `shouldAddParenthesesToMemberExpressionObject`.
3. **Semicolons** - If a fix makes a statement start with `[` or `(`, check `needsSemicolon()` and prepend `;` if needed.
4. **Spacing** - Replacing `{foo}` with an identifier may merge tokens: `const{foo}` becomes `constfoo`. Add spaces when a symbol-boundary becomes a letter-boundary.
5. **Generator fixes** - Use `* fix(fixer) { yield ... }` for multi-step fixes.
6. **Suggestions** - Use `suggest` array with `messageId` and `fix` when autofix could change runtime behavior. Set `hasSuggestions: true` in meta.
7. **Indentation and line endings** - ESLint inserts fix text verbatim and does not re-indent it. Never hardcode `\t` or `\n` when building new lines. Use the helpers in `rules/utils/`: `getLineIndent(node, context)` for the indentation of the line a node is on, `getIndentUnit(context)` for one level of the file's indentation, `getLinebreak(context)` for the file's line ending, and `reindentText(text, sourceIndent, targetIndent)` to move multi-line text to another indentation. Test fixes with space-indented and CRLF input, not only tabs and LF.
## Rule naming
Use a clear prefix that signals intent (see [ESLint built-in rules](https://eslint.org/docs/latest/rules/) for inspiration):
- **`no-`** - Disallow something: `no-array-reduce`, `no-await-in-promise-methods`
- **`prefer-`** - Suggest a better alternative: `prefer-array-flat-map`, `prefer-set-has`
- **`require-`** - Mandate something is present: `require-array-join-separator`
- **`consistent-`** - Enforce a single consistent style: `consistent-destructuring`
- **No prefix** - Enforce a specific pattern: `error-message`, `filename-case`, `throw-new-error`
Name after the target construct, not the fix. Be specific: `no-array-method-this-argument` not `no-this-argument`.
## Creating a new rule
1. Run `npm run create-rule` to scaffold the rule file, test file, and doc file. This also regenerates `rules/index.js` and updates doc headers.
2. Write tests in `test/<rule>.js` before implementing the rule.
3. Implement the rule in `rules/<rule>.js`.
4. Write documentation in `docs/rules/<rule>.md` (below the auto-generated header).
5. Run `node --test --test-update-snapshots test/<rule>.js` to create the snapshots, review them, then run `node --test test/<rule>.js` to verify tests pass.
6. Before pushing, run lint (`npm run lint:js`, which runs `eslint` — see [Linting](#linting)), dogfooding (`npm run run-rules-on-codebase`), and then `npm test`. If dogfooding finds intentional internal patterns, disable the rule in `eslint.dogfooding.config.js` instead of adding repo-specific heuristics.
## Commit message format
Follow these conventions:
- **New rule**: `` Add `rule-name` rule ``
- **Fix/improve existing rule**: `` `rule-name`: Short description ``
- **General fix**: `Fix short description`
- **Add option to rule**: `` `rule-name`: Add `optionName` option ``
- **Drop a rule**: `` Drop `rule-name` rule ``
Always use backticks around rule names and option names in commit messages.
More agent context in sindresorhus/eslint-plugin-unicorn
One other file this repository gives its agents.
CLAUDE.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
No reports yet. Be the first to say whether it worked.
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

