eslint-plugin-legibility
v0.3.0
Published
ESLint and Oxlint JS plugin rules for readable, performance-conscious code.
Maintainers
Readme
eslint plugin legibility
Why was this written?
Working with LLM's for the majority of my work, I find the way that I code and read code has changed. This project contains rules I find useful for keeping Typescript and/or JavaScript more readable when written mainly by LLMs's.
TLDR;
The goal of rules in this package are to make code readable for reviewing lots of code and avoiding things that have a high probability of complexity or confusion.
Install
This project provides ESLint and Oxlint-compatible rules for readable, explicit, performance-conscious JavaScript and TypeScript.
The package exports an ESLint-compatible plugin object. ESLint can load it as a normal plugin, and Oxlint can load the same package through JavaScript plugin support.
# npm, pnpm, bun
npm add -D eslint-plugin-legibilityRules
Rules marked recommended + strict are enabled by recommended as warn and by strict as error. Rules marked strict only are enabled by strict as error. Comment rules are recipe only because agent sessions, development, and commit gates need different severities and options.
| Rule | Preset configuration | Options |
| --- | --- | --- |
| legibility/hoist-if-operators | recommended + strict | {max: 0, operators: ["&&", "\|\|", "??", "?:"], complexity} |
| legibility/max-array-chain-depth | recommended + strict | {max: 2, iterationMethods} |
| legibility/max-control-flow-depth | recommended + strict | {max: 3} |
| legibility/max-expression-operators | recommended + strict | {max: 4, operators, complexity} |
| legibility/max-function-parameters | recommended + strict | {max: 4, maxObjectProperties: 8} |
| legibility/no-automated-comment-attribution | recipe only | {identifiers} |
| legibility/no-complex-ternaries | recommended + strict | {max: 2, operators, complexity} |
| legibility/no-computed-values | recommended + strict | {max: 1, operators, complexity} |
| legibility/no-direct-node-bin-smoke | recommended + strict | {entryPatterns} |
| legibility/no-hidden-side-effects | recommended + strict | {mutatingMethods, sideEffectFreeIterationMethods} |
| legibility/no-identity-array-callback | recommended + strict | none |
| legibility/no-quadratic-patterns | recommended + strict | {iterationMethods, searchMethods} |
| legibility/no-redundant-boolean-logic | recommended + strict | {equalityOperators: ["==", "===", "!=", "!=="]} |
| legibility/no-redundant-nullish-fallback | recommended + strict | none |
| legibility/no-repeated-collection-search | strict only | {searchMethods} |
| legibility/no-single-use-renaming-alias | strict only | none |
| legibility/no-stacked-comments | recipe only | none |
| legibility/no-standalone-array-mutations | recommended + strict | {arrayMutatingMethods, mutatingMethods} |
| legibility/no-trivial-wrapper-functions | recommended + strict | none |
| legibility/no-unmatched-comments | recipe only | {matchers: [], prefixIdentifiers: [], suffixIdentifiers: []} |
| legibility/no-unnecessary-block-callback | recommended + strict | none |
| legibility/prefer-concat-object-assign | recommended + strict | none |
| legibility/prefer-early-return | recommended + strict | none |
| legibility/prefer-flat-map | recommended + strict | none |
| legibility/prefer-guard-clauses | recommended + strict | none |
| legibility/prefer-object-lookup | recommended + strict | {min: 3, operators: ["==", "==="]} |
| legibility/prefer-positive-condition-names | strict only | {booleanOperators} |
| legibility/require-executable-shebang | recommended + strict | {files, runtimes: ["bun", "deno", "node"]} |
| legibility/require-jsdoc-multiline-comments | recipe only | none |
legibility/hoist-if-operators({options})
Prefer a named boolean before an operator-heavy if condition.
options
{max: number}: allowed weighted operators in the condition. Default:0.{operators: string[]}: operators to count. Default:["&&", "||", "??", "?:"].{complexity: Record<string, number>}: per-operator weights.
do / don't
- if (user && user.isActive && !user.isLocked) {
- sendInvite(user);
- }
+ const canInviteUser = user && user.isActive && !user.isLocked;
+
+ if (canInviteUser) {
+ sendInvite(user);
+ }legibility/max-array-chain-depth({options})
Limit chained array methods like items.filter().map().some().
options
{max: number}: allowed chained items. Default:2.{iterationMethods: string[]}: method names that count as chain items.
do / don't
- const hasLargeActiveItem = items
- .filter((item) => item.active)
- .map((item) => item.size)
- .some((size) => size > 100);
+ const activeItems = items.filter((item) => item.active);
+ const itemSizes = activeItems.map((item) => item.size);
+ const hasLargeActiveItem = itemSizes.some((size) => size > 100);legibility/max-control-flow-depth({options})
Limit nested branches and loops.
options
{max: number}: allowed nested control-flow depth. Default:3.
do / don't
- if (user) {
- if (user.active) {
- if (user.email) {
- sendInvite(user);
- }
- }
- }
+ if (!user) return;
+ if (!user.active) return;
+ if (!user.email) return;
+
+ sendInvite(user);legibility/max-expression-operators({options})
Limit operators inside one expression.
options
{max: number}: allowed weighted operators. Default:4.{operators: string[]}: operators to count.{complexity: Record<string, number>}: per-operator weights.
do / don't
- return user && user.active && (user.role === "admin" || user.role === "owner");
+ const isAdmin = user.role === "admin";
+ const isOwner = user.role === "owner";
+ const hasPrivilegedRole = isAdmin || isOwner;
+
+ return user && user.active && hasPrivilegedRole;legibility/max-function-parameters({options})
Limit the inputs a function exposes. The rule checks both top-level parameters and the properties listed by each destructured object parameter.
TypeScript ambient declarations and function types are checked. A leading TypeScript this parameter is ignored because callers do not supply it.
options
{max: number}: allowed top-level parameters. Default:4.{maxObjectProperties: number}: allowed properties in one destructured object parameter. Default:8.
do / don't
- function schedule(user, plan, timezone, locale, notify) {}
+ function schedule(request, deliveryOptions) {}
- function publish({ article, author, channel, locale, schedule, tags, theme, tracking, visibility }) {}
+ function publish(article, publicationOptions) {}legibility/no-complex-ternaries({options})
Reject nested ternaries and operator-heavy ternaries.
options
{max: number}: allowed weighted operators inside one ternary. Default:2.{operators: string[]}: operators to count.{complexity: Record<string, number>}: per-operator weights.
do / don't
- const label = isLoading ? "Loading" : hasError ? "Error" : "Ready";
+ const label = getStatusLabel({ hasError, isLoading });legibility/no-computed-values({options})
Prefer named values before computed returns and object values.
options
{max: number}: allowed weighted operators in a computed value. Default:1.{operators: string[]}: operators to count.{complexity: Record<string, number>}: per-operator weights.
do / don't
- return subtotal + tax - discount;
+ const total = subtotal + tax - discount;
+
+ return total;legibility/no-direct-node-bin-smoke({options})
Smoke-test installed package bins instead of direct node src/index.js execution.
options
{entryPatterns: string[]}: entry files that should be tested through the installed bin shim.
do / don't
- execSync("node src/index.js --help");
+ execSync("my-cli --help");legibility/no-hidden-side-effects({options})
Keep mutations out of nested expressions and side-effect-free callbacks.
options
{mutatingMethods: string[]}: method calls treated as mutations.{sideEffectFreeIterationMethods: string[]}: callback methods expected to stay side-effect-free.
do / don't
- return (count += 1);
+ count += 1;
+
+ return count;legibility/no-identity-array-callback()
Reject map and filter callbacks that keep every item unchanged.
do / don't
- const nextItems = items.map((item) => item);
+ const nextItems = items;legibility/no-quadratic-patterns({options})
Flag nested loops, nested array iteration, and collection searches inside loop bodies.
options
{iterationMethods: string[]}: methods checked for nested iteration.{searchMethods: string[]}: methods treated as collection searches.
do / don't
- const enrichedOrders = orders.map((order) => ({
- ...order,
- user: users.find((user) => user.id === order.userId),
- }));
+ const usersById = new Map(users.map((user) => [user.id, user]));
+ const enrichedOrders = orders.map((order) => ({
+ ...order,
+ user: usersById.get(order.userId),
+ }));legibility/no-redundant-boolean-logic({options})
Avoid boolean comparisons and boolean-only ternaries.
options
{equalityOperators: string[]}: operators checked for comparisons againsttrueorfalse. Default:["==", "===", "!=", "!=="].
do / don't
- return isReady === true ? true : false;
+ return isReady;legibility/no-redundant-nullish-fallback()
Avoid ?? undefined fallbacks.
do / don't
- const value = maybeValue ?? undefined;
+ const value = maybeValue;legibility/no-repeated-collection-search({options})
Flag repeated searches over the same collection in one scope.
options
{searchMethods: string[]}: methods treated as collection searches.
do / don't
- const owner = users.find((user) => user.id === ownerId);
- const reviewer = users.find((user) => user.id === reviewerId);
+ const usersById = new Map(users.map((user) => [user.id, user]));
+ const owner = usersById.get(ownerId);
+ const reviewer = usersById.get(reviewerId);legibility/no-single-use-renaming-alias()
Avoid aliases that only rename another value for one use.
do / don't
- const userData = user;
-
- return userData.name;
+ return user.name;legibility/no-standalone-array-mutations({options})
Prefer explicit returned array composition over standalone array mutation statements.
options
{arrayMutatingMethods: string[]}: array methods reported when used as standalone mutations.{mutatingMethods: string[]}: mutation methods used to identify fresh mutation targets.
do / don't
- items.push(nextItem);
-
- return items;
+ return items.concat(nextItem);legibility/no-trivial-wrapper-functions()
Avoid wrappers that only forward their parameters to another call.
do / don't
- const getUser = (userId) => fetchUser(userId);
+ const getActiveUser = (userId) => fetchUser(userId).then(requireActiveUser);Comment rules
Comment rules are opt-in because agent sessions, development, and commit gates need different severities. Start with the comment policy recipes, then use this section as the option reference.
| Rule | Responsibility |
| --- | --- |
| legibility/no-unmatched-comments | Reject comments without an explicitly configured matcher, prefix, or suffix. |
| legibility/no-stacked-comments | Report comments stacked on consecutive lines. |
| legibility/no-automated-comment-attribution | Reject explicit automated attribution signatures. |
| legibility/require-jsdoc-multiline-comments | Require JSDoc syntax for multiline block comments. |
legibility/no-unmatched-comments({options})
Reject every comment that lacks an explicitly configured regular-expression matcher, prefix identifier, or suffix identifier.
A comment is accepted when any configured matchers, prefixIdentifiers, or suffixIdentifiers entry matches. The rule reports unmatched comments without modifying them.
No matcher or identifier is configured by default. Enabling the rule without options rejects all line and block comments.
options
| Option | Type | Default | Behavior |
| --- | --- | --- | --- |
| matchers | string[] | [] | Case-insensitive regular-expression sources matched against the normalized comment body. |
| prefixIdentifiers | string[] | [] | Case-insensitive literal identifiers matched at the start of the normalized comment body. |
| suffixIdentifiers | string[] | [] | Case-insensitive literal identifiers matched at the end of the normalized comment body. |
The arrays are independent allow paths. A matching prefix or suffix lets ESLint allow the comment. matchers provides the regular-expression option.
matching details
- Line comments, inline comments, trailing comments, and block comments are checked.
- Executable shebang tokens are ignored.
- Regular-expression matchers run against the trimmed comment body without delimiters or leading JSDoc
*prefixes. - Regular-expression matchers are compiled with the
ianduflags. - Invalid regular expressions are ignored. If no other allow path matches, the comment is rejected.
- Empty or whitespace-only prefix and suffix identifiers never match.
- The rule has no autofix.
legibility/no-stacked-comments()
Report comments stacked on consecutive lines. A stack signals that the adjacent comment should be updated or removed instead of adding another comment.
- // Retry every failed request.
// Retry requests that fail during regional failover.A blank line between comments is not a stack. The rule has no options and no autofix.
legibility/no-automated-comment-attribution({options})
Reject explicit automated authorship and generation signatures in comments.
Static linting cannot determine how unmarked prose was produced. This rule only detects configured identifiers in structured attribution tags and phrases equivalent to generated by <identifier> or <identifier>-generated.
options
{identifiers: string[]}: case-insensitive names treated as automated sources. Default:ai,chatgpt,claude,codex,copilot,gemini,gpt,llm, andopenai.
examples
| Comment content | Result |
| --- | --- |
| Generated by <configured identifier> | rejected |
| <configured identifier>-generated | rejected |
| Send a request to <configured identifier> | accepted |
| Unmarked prose | not classified |
Use custom identifiers when a repository has additional automated tools:
{
"legibility/no-automated-comment-attribution": [
"error",
{
identifiers: ["assistant", "robot"],
},
],
}legibility/require-jsdoc-multiline-comments()
Require block comments spanning multiple lines to use /** ... */ JSDoc syntax. Line comments and single-line block comments are unchanged.
- /*
- * The provider can return a stale token during regional failover.
- * Preserve the retry order.
- */
+ /**
+ * The provider can return a stale token during regional failover.
+ * Preserve the retry order.
+ */This rule has no options and no autofix.
legibility/no-unnecessary-block-callback()
Prefer expression-bodied arrow callbacks when the callback block only returns.
do / don't
- const ids = users.map((user) => {
- return user.id;
- });
+ const ids = users.map((user) => user.id);legibility/prefer-concat-object-assign()
Prefer concat and Object.assign over spread composition.
do / don't
- const nextItems = [...items, nextItem];
- const options = { ...defaults, ...overrides };
+ const nextItems = items.concat(nextItem);
+ const options = Object.assign({}, defaults, overrides);legibility/prefer-early-return()
Avoid else branches after an if branch already exits.
do / don't
- if (!user) {
- return null;
- } else {
- return user.name;
- }
+ if (!user) {
+ return null;
+ }
+
+ return user.name;legibility/prefer-flat-map()
Prefer flatMap over map(...).flat().
do / don't
- const permissions = users.map((user) => user.permissions).flat();
+ const permissions = users.flatMap((user) => user.permissions);legibility/prefer-guard-clauses()
Prefer guard clauses over wrapping a whole function body in one branch.
do / don't
- function sendInvite(user) {
- if (user) {
- const email = buildEmail(user);
- deliver(email);
- }
- }
+ function sendInvite(user) {
+ if (!user) return;
+
+ const email = buildEmail(user);
+ deliver(email);
+ }legibility/prefer-object-lookup({options})
Prefer Set, Map, or object lookups over long equality || chains.
options
{min: number}: equality checks required before reporting. Default:3.{operators: string[]}: equality operators that count. Default:["==", "==="].
do / don't
- const isSupported = type === "page" || type === "post" || type === "asset";
+ const supportedTypes = new Set(["page", "post", "asset"]);
+ const isSupported = supportedTypes.has(type);legibility/prefer-positive-condition-names({options})
Prefer positive boolean names over names like isNotReady.
options
{booleanOperators: string[]}: binary operators that mark an initializer as boolean-like.
do / don't
- const isNotReady = status !== "ready";
-
- if (!isNotReady) {
- run();
- }
+ const isReady = status === "ready";
+
+ if (isReady) {
+ run();
+ }legibility/require-executable-shebang({options})
Require configured CLI entry source files to include a Node, Bun, or Deno shebang.
options
{files: string[]}: source files expected to be executable entries.{runtimes: string[]}: accepted shebang runtimes. Default:["bun", "deno", "node"].
do / don't
- console.log("hello");
+#!/usr/bin/env node
+
+ console.log("hello");Operator Options
The operator-counting rules accept the same option shape:
legibility/hoist-if-operatorslegibility/max-expression-operatorslegibility/no-complex-ternarieslegibility/no-computed-values
{
rules: {
"legibility/max-expression-operators": [
"warn",
{
max: 4,
operators: ["&&", "||", "??", "?:", "!", "===", "!=="],
complexity: { "?:": 2 }
}
]
}
}Chain And Count Options
Use max and min to tune rule sensitivity.
{
rules: {
"legibility/max-array-chain-depth": ["warn", { max: 3 }],
"legibility/max-control-flow-depth": ["warn", { max: 2 }],
"legibility/prefer-object-lookup": ["warn", { min: 4 }]
}
}Recipes
Comment rules are absent from the bundled presets. Add the rules needed for each lint mode.
Agent mode
Agents do not add comments by default:
import legibility from "eslint-plugin-legibility";
const commentRules = {
"legibility/no-stacked-comments": "error",
"legibility/no-unmatched-comments": "error",
};
export default [
legibility.configs["flat/recommended"],
{ rules: commentRules },
];To allow marked comments, configure a prefix, suffix, or both:
const prefixIdentifiers = ["APPROVED"];
const suffixIdentifiers = ["@approved"];
const commentOptions = { prefixIdentifiers, suffixIdentifiers };
const unmatchedComments = ["error", commentOptions];
const commentRules = {
"legibility/no-stacked-comments": "error",
"legibility/no-unmatched-comments": unmatchedComments,
};Either matching identifier allows the comment. matchers and the other comment rules provide additional configuration in the comment rule reference.
Install the packaged skill and lint changed files:
npx eslint-plugin-legibility-install-skill --target codex
npx lint-changedDeveloper mode
Use warnings while editing:
const commentRules = {
"legibility/no-stacked-comments": "warn",
"legibility/no-unmatched-comments": "warn",
};Use the same no-unmatched-comments options when the repository allows marked comments.
Commit gate
Promote the chosen comment policy to errors:
const commentRules = {
"legibility/no-stacked-comments": "error",
"legibility/no-unmatched-comments": "error",
};Use the configured prefix, suffix, or other matcher here when the repository allows comments.
A pre-commit hook can run the configured ESLint rules:
#!/bin/sh
set -eu
pnpm exec eslint --max-warnings 0 .This repository can install its managed validation hook with pnpm install-hooks.
Configs
| Config | Format | Behavior | | --- | --- | --- | | recommended | ESLint legacy | High-signal general rules as warnings. | | strict | ESLint legacy | General rules as errors. | | flat/recommended | ESLint flat config | High-signal general rules as warnings. | | flat/strict | ESLint flat config | General rules as errors. |
Usage
Using With ESLint
Flat config:
import legibility from "eslint-plugin-legibility";
export default [legibility.configs["flat/recommended"]];Configure rules directly:
import legibility from "eslint-plugin-legibility";
export default [
{
plugins: { legibility },
rules: {
"legibility/max-array-chain-depth": ["warn", { max: 2 }],
"legibility/max-expression-operators": ["warn", { max: 4 }],
"legibility/no-quadratic-patterns": "warn",
},
},
];CommonJS compatibility:
const legibility = require("eslint-plugin-legibility");Legacy config:
module.exports = {
plugins: ["legibility"],
extends: ["plugin:legibility/recommended"],
};Usage With Oxlint
Oxlint JavaScript plugins use the same ESLint-compatible rule API.
{
"jsPlugins": [
{
"name": "legibility",
"specifier": "eslint-plugin-legibility"
}
],
"rules": {
"legibility/max-array-chain-depth": ["warn", { "max": 2 }],
"legibility/max-expression-operators": ["warn", { "max": 4 }],
"legibility/no-quadratic-patterns": "warn"
}
}Agent Skill
Install the packaged agent skill after installing the npm package:
npx eslint-plugin-legibility-install-skillInstall for a specific agent target:
npx eslint-plugin-legibility-install-skill --target codex
npx eslint-plugin-legibility-install-skill --target claudeAPI
Rules are configured through ESLint or Oxlint rules.
{
rules: {
"legibility/rule-name": ["warn", { option: "value" }]
}
}legibility.configs["flat/recommended"]
Flat ESLint config with high-signal general rules enabled as warnings. Comment policies are configured through recipes.
legibility.configs["flat/strict"]
Flat ESLint config with general rules enabled as errors. Comment policies are configured through recipes.
legibility.configs.recommended
Legacy ESLint config with high-signal general rules enabled as warnings. Comment policies are configured through recipes.
legibility.configs.strict
Legacy ESLint config with general rules enabled as errors. Comment policies are configured through recipes.
Security Posture
- No runtime dependencies.
- Published package contents are allowlisted with
files. - Releases are tag-triggered and publish GitHub release assets.
- npm publishing uses GitHub Actions trusted publishing with provenance.
- CI runs validation on Node 20, 22, 24, and 26, plus compatibility suites on Bun and Deno.
- Bun installs are configured to use Socket.dev's security scanner.
GitHub Secrets
| Secret | Location | Used by | Required when |
| --- | --- | --- | --- |
| CODECOV_TOKEN | Repository Actions secret | .github/workflows/codecov.yml | Codecov uploads run on protected branches or token authentication is required in Codecov. |
| SOCKET_SECURITY_API_KEY | Repository Actions secret | GitHub workflows that install, analyze, test, or publish packages | Socket.dev scanning or package-manager security integration is enabled. |
| SOCKET_API_KEY | Repository Actions secret | Fallback Socket token name | Existing Socket automation expects this older token name. |
GITHUB_TOKEN is provided by GitHub Actions automatically and does not need to be added manually.
npm publishing does not use NPM_TOKEN or NODE_AUTH_TOKEN. Configure npm trusted publishing for:
| Field | Value |
| --- | --- |
| Provider | GitHub Actions |
| Repository owner | yowainwright |
| Repository name | eslint-plugin-legibility |
| Workflow filename | publish.yml |
| Environment | blank |
| Allowed action | npm publish |
Releases
pnpm release:current:dry
pnpm release:current
pnpm release:patch:dry
pnpm release:patch
pnpm release:minor
pnpm release:major
pnpm release:beta
pnpm release:alphaReleases use a local release wrapper around release-it. Run release commands from a clean, up-to-date main branch. Use pnpm release:current for the first publish of the current package version, then use the patch, minor, major, alpha, or beta commands for later releases. The wrapper resolves the exact version, verifies local main matches origin/main, and asks for confirmation before release-it pushes the tag that triggers npm publishing.
The publish confirmation question is:
Publish eslint-plugin-legibility@<version> from GitHub Actions trusted publishing? This will push v<version> and npm <dist-tag> will update if the workflow succeeds. Continue? [y/N]Answer y or yes to continue. Any other answer aborts before the release tag is pushed. For intentional noninteractive release automation, pass --yes to the release wrapper.
After confirmation, release-it runs pnpm validate, bumps package.json when incrementing, creates the release commit, creates v${version}, and pushes the branch with tags. The pushed tag triggers npm publishing and GitHub release asset upload through .github/workflows/publish.yml.
Before publishing, configure npm trusted publishing for publish.yml. Leave the environment field blank because the publish workflow does not use a GitHub environment.
Attribution
The first rules were adapted from the Pastoralist scripts/oxlint-plugin rule set, then packaged for ESLint and Oxlint with additional legibility and performance rules.
