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

@re-cinq/eslint-plugin-re-lint

v1.8.0

Published

re:cinq house ESLint rules: complexity, naming, functions, comments, tests, layering, spec traceability, and jscpd-backed duplicate detection. Flat config, plugin key re-lint.

Readme

@re-cinq/eslint-plugin-re-lint

re:cinq's house ESLint rules for TypeScript repositories: complexity budgets, naming, function shape, comments, tests, folder layering, spec traceability, and jscpd-backed duplicate detection. Flat config only. The plugin key is re-lint, so every rule reads re-lint/<name>.

The rules were extracted from the re-cinq/lore monorepo, where each one was introduced against a real failure mode and driven to zero findings before being promoted to error. Most of them encode the Clean Code cheat-sheet; the mapping is at the end of this document.

Install

npm i -D @re-cinq/eslint-plugin-re-lint eslint typescript-eslint

Optional peers, needed only by the rules that use them:

| Peer | Needed by | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | @stylistic/eslint-plugin | the recommended preset's blank-line rules (pass it in; omitted otherwise) | | @eslint/markdown | the six markdown rules (require-*, no-dead-md-links, no-stale-spec-links, no-ungrounded-spec-name), which run under language: markdown/gfm | | @eslint/css | prefer-design-tokens, which runs on stylesheets under language: css/css | | typescript | no-forwarding-class (type-aware; needs parserOptions.projectService) | | jscpd (5.x) | no-duplicate-code |

Node 20 or newer, ESLint 9 or newer.

Quick start

The recommended preset switches on every rule that needs no repository knowledge, plus the ESLint core and typescript-eslint rules that back the same guidelines (complexity 6, max-lines-per-function 30, max-params 3, max-depth 2, no-magic-numbers, id-length, no-negated-condition, naming-convention without I prefixes or Type suffixes, and more). typescript-eslint and @stylistic/eslint-plugin are passed in rather than imported, so the package depends on neither; leave one out and its rules are left out too.

import tseslint from "typescript-eslint";
import stylistic from "@stylistic/eslint-plugin";
import reLint from "@re-cinq/eslint-plugin-re-lint";

export default tseslint.config(
  { ignores: ["dist/**", "node_modules/**"] },
  ...tseslint.configs.recommended,
  ...reLint.configs.recommended({ tseslint, stylistic }),
);

Or wire rules by hand:

import reLint from "@re-cinq/eslint-plugin-re-lint";

export default [
  {
    files: ["src/**/*.{ts,tsx}"],
    plugins: { "re-lint": reLint },
    rules: {
      "re-lint/no-nested-if": "error",
      "re-lint/no-flag-params": "error",
      "re-lint/max-boolean-operators": ["error", { max: 2 }],
    },
  },
];

Every rule is error in the preset. Repositories adopting the package on an existing codebase override the noisy ones to warn (see Adopting in an existing repository).

Rule reference

Options are listed with their defaults. "Preset" says whether the rule is in configs.recommended; rules marked "opt-in" need an option or a files scope that only the consumer knows, and report nothing until they get it.

Complexity and control flow

| Rule | Reports | Options | Fix | Preset | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --- | ------ | | no-nested-if | an if inside another if in the same function; else if chains are one level | | yes | yes | | no-nested-loop | a loop inside another loop in the same function | | | yes | | prefer-early-return | a function whose body is one big if; inverts it into a guard clause | | yes | yes | | max-boolean-operators | a condition or assignment with more than max &&/\|\| operators; extract a named predicate | max: 2 | | yes | | no-catch-as-control-flow | a catch that swallows or branches on an error instead of handling it | | | yes | | prefer-polymorphism | a switch or if/else-if chain dispatching on an object's tag (x.type, x.kind) with minCases or more branches | minCases: 3, discriminants: [] (any property when empty) | | yes | | max-member-chain | a member chain deeper than max hops (Law of Demeter); this, well-known globals, allowRoots, and all-call fluent chains are exempt | max: 2, allowRoots: [] | | yes |

Names

| Rule | Reports | Options | Fix | Preset | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --- | ----------------------------- | | no-vague-names | declarations named data, temp, obj, x, and the rest of the blocklist | names: [...] (replaces the default list) | | yes | | no-negative-names | isNotReady, notFound, hasNoItems, disableX, hideX, preventX; suggests the positive name | allow: [] | | yes | | default-export-matches-filename | a default export whose name differs from the file's; for Next.js app dirs, reserved files must be pure re-exports | reserved: "error" \| "off" | | opt-in (scope to the app dir) |

Functions

| Rule | Reports | Options | Fix | Preset | | ----------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------- | ------------------------- | | no-flag-params | a parameter typed boolean or defaulted to true/false, and a bare true/false argument at a call | allowNamed: true (destructured params and { force: true } objects are not flags) | | yes | | no-prop-mutation | assignment into a function's parameters (props, args) instead of returning a new value | | | opt-in (scope to UI code) | | no-forwarding-class | a class that only forwards 1:1 to its single injected port; pass the port itself. Type-aware | | | yes | | require-fetch-timeout | an outbound fetch without a signal | timeoutMs (none: report only; set: --fix adds signal: AbortSignal.timeout(timeoutMs)) | yes (needs timeoutMs) | yes | | callee-below-caller | a top-level function declared above the function that first calls it | exportedFirst: true | | yes | | declare-near-use | a const/let declared more than maxDistance statements before its first use in the same block | maxDistance: 5 | | yes |

Comments

| Rule | Reports | Options | Fix | Preset | | --------------------------- | ------------------------------------------------------------------------------------------- | -------- | -------------------------- | ------------------ | | max-comment-lines | a comment (or run of line comments) longer than max lines; 0 bans comments | max: 1 | | yes (0 in tests) | | no-commented-out-code | a comment whose text parses as code; consecutive line comments are one candidate | | | yes | | no-closing-brace-comments | } // end if, }); // end describe: a comment after a closing brace of a multi-line block | | yes (end … markers only) | yes |

Objects and modules

| Rule | Reports | Options | Fix | Preset | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | --- | ------------------------- | | no-hybrid-class | a class with public mutable fields and methods: half data structure, half object | ignoreDecorated: true | | yes | | no-reexport-only-module | a module whose whole top level is re-exports; forward from an index instead | allow: ["page.tsx", "layout.tsx", "route.ts"] | | yes | | no-inline-styles | style={{ ... }} in JSX; use a colocated stylesheet | | | opt-in (scope to UI code) | | no-unguarded-async-button | a <button type="submit">, or a button whose onClick is an async function, with no disabled binding; a second click starts the request again | components: [] (a consumer's own button components), pendingComponent, submitComponent (named in the message) | | opt-in (scope to UI code) | | no-io-in-view | a presentational component (<name>View.tsx and friends) importing the data layer or calling the network | viewSuffixes: ["View","Card","Table","Section","Badge","Row"], dataModules: [] (required) | | opt-in | | no-sql-in-web-ui | SQL strings or a database client in UI code; move the query behind an API route | | | opt-in (scope to UI code) | | no-row-types-outside-models | a type restating a table's columns outside the models directory | modelsDir: "", exemptNames: [] | | opt-in | | prefer-enforce-true | if (!cond) throw ... guards; rewrites to enforceTrue(cond, Error, message) imported from enforceModule | enforceModule: { specifier, sourceDir? } (required) | yes | opt-in | | prefer-api-error | an if-return answering h.response({ error }).code(4xx); rewrites to enforceTrue(cond, apiError(status)) | enforceModule, errorModules: [{ root, path }] (required) | yes | opt-in |

Architecture

| Rule | Reports | Options | Fix | Preset | | ----------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --- | ------ | | no-forbidden-imports | an import of a configured specifier or any subpath under it | forbidden: [{ specifier, message? }] (required) | | opt-in | | no-cross-layer-import | an import that crosses the folder layering declared in a layers.yaml found by walking up from the file | layers, aliases, root (inline alternative to the file), firstPartyScopes: [] | | opt-in |

A layers.yaml lists, per folder, what it may import:

layers:
  domain: []
  work: [domain]
  outbound: [domain]
  transport: [domain, work, outbound]
tests: ["**/*.test.ts"]

Repository-layout rules in one place, with the values the Lore monorepo uses:

import reLint from "@re-cinq/eslint-plugin-re-lint";

export default [
  {
    files: ["apps/web-ui/src/**/*.{ts,tsx}"],
    plugins: { "re-lint": reLint },
    rules: {
      "re-lint/no-inline-styles": "error",
      "re-lint/no-prop-mutation": "error",
      "re-lint/no-sql-in-web-ui": "error",
      "re-lint/no-unguarded-async-button": [
        "error",
        { pendingComponent: "PendingButton", submitComponent: "SubmitButton" },
      ],
      "re-lint/default-export-matches-filename": "error",
      "re-lint/no-io-in-view": [
        "error",
        { dataModules: ["@/lib/db", "@/lib/github"] },
      ],
    },
  },
  {
    files: ["apps/floor/src/**/*.ts"],
    plugins: { "re-lint": reLint },
    rules: {
      "re-lint/no-forbidden-imports": [
        "error",
        {
          forbidden: [
            {
              specifier: "@google-cloud/storage",
              message: "Reach storage through the shared port adapters.",
            },
          ],
        },
      ],
    },
  },
  {
    files: ["**/*.ts"],
    plugins: { "re-lint": reLint },
    rules: {
      "re-lint/no-row-types-outside-models": [
        "error",
        { modelsDir: "libs/shared/src/models", exemptNames: ["PipelineTask"] },
      ],
      "re-lint/prefer-enforce-true": [
        "error",
        {
          enforceModule: {
            specifier: "@re-cinq/lore-shared/lib/enforce.js",
            sourceDir: "libs/shared/src",
          },
        },
      ],
      "re-lint/test-imports-its-subject": [
        "error",
        { firstPartyScopes: ["@re-cinq"] },
      ],
      "re-lint/no-cross-layer-import": [
        "error",
        { firstPartyScopes: ["@re-cinq"] },
      ],
    },
  },
  {
    files: ["apps/lore-api/src/**/*.ts", "apps/floor/src/**/*.ts"],
    plugins: { "re-lint": reLint },
    rules: {
      "re-lint/prefer-api-error": [
        "error",
        {
          enforceModule: { specifier: "@re-cinq/lore-shared/lib/enforce.js" },
          errorModules: [
            { root: "apps/lore-api/src", path: "server/api-error.js" },
            { root: "apps/floor/src", path: "delivery/http/api-error.js" },
          ],
        },
      ],
    },
  },
];

Tests

| Rule | Reports | Options | Fix | Preset | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | --- | ---------------- | | max-expects | more than max expect(...) calls in one it/test body; expect.assertions is free | max: 1 | | yes (test files) | | no-nondeterministic-tests | Math.random, Date.now, new Date(), performance.now, crypto.randomUUID in a test file unless that family is faked | | | yes (test files) | | require-colocated-tests | a test under __tests__/ instead of beside its source | | | yes | | test-imports-its-subject | a test that never imports the real module it names; firstPartyScopes says which npm scopes are yours | firstPartyScopes: [] | | yes | | require-spec-link | a test not linked from any spec or ADR via an inline ([validated by](test.ts#L12)) link | specsRoot: ".", roots: ["specs", "adrs"] | | opt-in |

Documents (markdown)

These run on spec.md and ADR files under language: "markdown/gfm" from @eslint/markdown. Statements in a spec carry their evidence inline:

Every task creates an Issue. ([validated by](src/tasks/create.test.ts#L42))

| Rule | Reports | Options | | --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | | require-intro-paragraph | a document without a lead paragraph between its title and the first section | roots: { spec: ["specs"], adr: ["adrs"] } | | require-statement-links | a testable statement with no ([validated by](...)) link, or whose link points at no test | roots, specsRoot: "." | | require-status-matches-coverage | a \| Status \| row that disagrees with link coverage: none is Draft, some is In Progress, all is Shipped | roots, specsRoot: "." | | no-dead-md-links | a markdown link to a repository file that does not exist, or a #Lnn past the end of the file | | | no-stale-spec-links | a [label](path#Lnn) link whose anchor no longer matches where the branch moved its test (fixable) | all: false, baseRef: "origin/main" | | no-ungrounded-spec-name | a repository path the document names in backticks, or bare in a quoted passage, that is not in the repository | roots |

The specs preset wires all six, plus require-spec-link over test files, the way the repository this package grew out of runs them: the three require-* document rules over specs/**/spec.md and adrs/**/*.md (require-statement-links at warn, so a spec still being linked does not block the branch, and no-ungrounded-spec-name likewise, since a corpus written before the rule names paths that have moved since), no-dead-md-links over every markdown file, and require-spec-link at error. @eslint/markdown is passed in, as typescript-eslint is to recommended:

import markdown from "@eslint/markdown";
import reLint from "@re-cinq/eslint-plugin-re-lint";

export default [...reLint.configs.specs({ markdown })];

A link only counts as coverage when it is evidence: its file exists in the repository and its #Lnn lands inside an it()/test() declaration (a link with no #L, or into a test in a language without it(), needs only the file; a link written as a URL, to another repository's test, cannot be checked here and counts as written). So a Shipped row cannot rest on a link to a deleted file or a line past every test, and require-statement-links names such a statement as carrying a link that is not evidence, not as missing one. specsRoot sets where links resolve from and defaults to the working directory.

require-status-matches-coverage fixes its own report by rewriting the status row to the one the evidence supports, in both directions: a spec that gained an unlinked statement drops from Shipped, and a fully linked Draft is promoted. It never edits a link; re-lint-reanchor repoints those. A CI job that runs eslint --fix and commits the result will therefore correct the status for the author. To keep the branch red instead, run that fixing pass with the rule off (--rule '{"re-lint/require-status-matches-coverage":"off"}') and leave it on in the lint job.

Or wire the rules by hand:

import markdown from "@eslint/markdown";
import reLint from "@re-cinq/eslint-plugin-re-lint";

export default [
  {
    files: ["specs/**/spec.md", "adrs/**/*.md"],
    plugins: { markdown, "re-lint": reLint },
    language: "markdown/gfm",
    rules: {
      "re-lint/require-intro-paragraph": "error",
      "re-lint/require-statement-links": "warn",
      "re-lint/require-status-matches-coverage": "error",
      "re-lint/no-ungrounded-spec-name": "warn",
      "re-lint/no-dead-md-links": "error",
    },
  },
  {
    files: ["**/*.test.{ts,tsx}"],
    plugins: { "re-lint": reLint },
    rules: {
      "re-lint/require-spec-link": ["error", { roots: ["specs", "adrs"] }],
    },
  },
];

Styles (CSS)

These run on stylesheets under language: "css/css" from @eslint/css. With languageOptions.tolerant they read SCSS nesting too; a value the parser keeps as raw text, such as $gap, is skipped.

| Rule | Reports | Options | | ---------------------- | ------------------------------------------------------------------------ | ----------------- | | prefer-design-tokens | a raw value in a governed property, where a var(--…) design token fits | groups, allow |

Each governed property belongs to one group, modelled on Bootstrap's variable scales. A value is raw when it is the literal kind the group's scale exists to replace:

| Group | Properties | Raw value | Bootstrap scale | | ------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------ | | color | color, background, border*, outline*, fill, stroke, text-decoration*, … | hex, named color, rgb()/hsl()/… with no var() inside | theme colors | | spacing | margin*, padding*, gap, row-gap, column-gap | a non-zero length | $spacers | | font-size | font-size | a non-zero length | $font-sizes | | font-weight | font-weight | a number | $font-weight-* | | line-height | line-height | a number or length | $line-height-* | | radius | border-radius, border-*-radius | a non-zero length | $border-radius-* | | shadow | box-shadow, text-shadow | a length or a color | $box-shadow-* | | z-index | z-index | a non-zero number | $zindex-* | | font-family | font-family | a font name | $font-family-* |

What passes: custom property definitions (the token file is where raw values live), anything inside var() including its fallback, 0, keywords (auto, inherit, transparent, currentColor, bold), percentages, a color function built from tokens, and the family an @font-face block defines. groups limits the check to the named groups; allow exempts exact values, such as a 1px hairline.

import css from "@eslint/css";
import reLint from "@re-cinq/eslint-plugin-re-lint";

export default [
  {
    files: ["**/*.{css,scss}"],
    plugins: { css, "re-lint": reLint },
    language: "css/css",
    languageOptions: { tolerant: true },
    rules: {
      "re-lint/prefer-design-tokens": ["warn", { allow: ["1px"] }],
    },
  },
];

Duplication: no-duplicate-code

Wraps jscpd 5, which is a native binary with no Node API. The rule runs it once per ESLint process over roots, indexes the JSON report by file, and reports every duplicated block in the file being linted with the block's full start and end position, so an editor underlines the whole clone and the message names its twin:

66 lines (330 tokens) duplicated with b.ts:10-75

| Option | Default | Meaning | | ----------- | -------- | ------------------------------------------------------------------------------------------------- | | roots | ["."] | directories jscpd scans, relative to the ESLint cwd; narrow on large repos | | minTokens | 50 | smallest clone reported | | minLines | jscpd's | smallest clone in lines | | mode | jscpd's | mild, weak (skip comments) or strict | | ignore | | file globs jscpd skips | | jscpdBin | resolved | path to jscpd's run-jscpd.js; by default found by walking up from cwd to a node_modules/jscpd |

How the scan is cached: one scan per process and option set. In a one-shot eslint run every file predates the scan, so nothing rescans. In an editor's long-lived ESLint server, saving a file makes it newer than the scan, and the rule rescans when that file, or the twin of any clone it holds, is linted; a clone you remove disappears on both sides in the same pass. When ESLint runs with --concurrency, each worker scans once. If jscpd cannot be found or fails, the rule reports a single unavailable message naming the install command instead of throwing.

import reLint from "@re-cinq/eslint-plugin-re-lint";

export default [
  {
    files: ["{apps,libs}/*/src/**/*.{ts,tsx}"],
    plugins: { "re-lint": reLint },
    rules: {
      "re-lint/no-duplicate-code": [
        "error",
        {
          roots: ["apps", "libs"],
          formats: ["typescript", "tsx"],
          minTokens: 50,
          ignore: ["**/dist/**", "**/*.d.ts", "**/fixtures/**"],
        },
      ],
    },
  },
];

Re-anchoring spec links: re-lint-reanchor

A #L42 link in a spec drifts the moment its cited file gains or loses a line above it. The re-lint-reanchor command heals those links from git, with no content search. The same logic runs inside ESLint as no-stale-spec-links, so eslint --fix repoints the links and a plain eslint fails the branch that left them stale; with no git work tree or merge base that rule reports nothing. It repoints only: an unmapped or rotten link is for the command's --check run.

npx re-lint-reanchor            # rewrite drifted links against origin/main
npx re-lint-reanchor --check    # CI: rewrite nothing, exit 1 on any finding
npx re-lint-reanchor --all develop
  • A link into a test file whose label names a test, as [validated by <test title>], [implemented by <test title>] or the bare [<test title>], moves to the line of the one it()/test() carrying that title. An anchor inside the test's body is kept while, mapped through the diff, it still lies in that test. A title two tests carry is reported, as is a validated by or implemented by title no test in the cited file carries; a bare label naming no test is mapped like any other link. A title in backticks is unwrapped, and a file.test.ts:NN label names no test.
  • Every other [label](path#Lnn) link is paired with its copy in the merge base's version of the markdown and mapped through the cited file's git diff -U0 hunks since git merge-base <base-ref> HEAD (plus MERGE_HEAD during an uncommitted merge). Reading the merge-base copy makes a second run a no-op. A link the branch added, or whose href it edited by hand, is kept as authored.
  • A ./ or ../ href is read beside the document (../../src/a.ts) and a / href from the repo root. A bare href (src/a.ts) is read from the root, or beside the document when no file sits at the root. A ../ href with no file beside the document is reported as rotten, even when the path exists from the root. A URL is left alone.
  • A cited line the branch deleted or rewrote is reported for a manual fix. An anchor on a blank or closing-punctuation line, past the end of its file, or into a missing file is reported as rotten. A bare [L42] label is synced to its href's line.

Only links into files the branch changed are touched, so a pull request carries no unrelated spec churn; --all sweeps every link. base-ref defaults to origin/main. The corpus defaults to specs/**/*.md, .specify/spec.md and adrs/**/*.md; each --corpus <glob> replaces it (**, * and ? are supported). Exit codes: 0 clean, 1 on an unmapped or rotten link (or, with --check, a stale or mislabelled one), 2 on a bad flag or a base ref that does not resolve.

The logic is a pure module, so another tool can drive it with its own git access:

import {
  createReanchorer,
  formatReanchorReport,
  parseHunks,
} from "@re-cinq/eslint-plugin-re-lint/spec/spec-reanchor.js";

const reanchor = createReanchorer(
  { workingFile, hunks: (path) => parseHunks(diffOf(path)), isChanged },
  { check: true, all: false },
);
const { text, tally } = reanchor({ docPath, source, baseSource });

It also exports the building blocks: anchorLinksIn, pairWithBase, findTestDeclarations, normalizeTitle, titleOfLabel, claimsTitle, mapLine, rottenReason, syncedLabel, selectCorpus, globToRegExp and DEFAULT_CORPUS.

Keeping specs true: the adoption recipe

A spec drifts in two ways: a statement stops describing what the tests prove, and a #Lnn anchor stops pointing at the line it cites. The repository this package grew out of holds both in check with four pieces, and every other repository gets the same discipline from this package alone:

  1. Statements carry their evidence. Every testable statement in specs/**/spec.md ends with ([validated by <test title>](path#Lnn)), and the | Status | row follows link coverage: no links is Draft, some is In Progress, all is Shipped. The specs preset above enforces this, fails a link to a file or line that does not exist, and reports a test no spec or ADR cites.

  2. Anchors heal on format. re-lint-reanchor runs as the last step of the repository's format script, so a test moved by an insertion above it takes its links along:

    {
      "scripts": {
        "format": "prettier --write . && re-lint-reanchor"
      }
    }
  3. CI keeps the branch honest. Either the format job commits the healed anchors back to the branch (check out with fetch-depth: 0, since the command scopes itself to the files changed against the merge base), or a check job runs re-lint-reanchor --check origin/$BASE_REF and fails on a stale, unmapped or rotten link. Both need the base branch fetched.

  4. Agents that write tests re-anchor too. Any recipe or prompt that adds or moves a test runs the format script before committing, so the links it shifted arrive healed rather than as the review's last red check.

The one thing the package cannot do is write the links. Authors add them by hand, or a tool that proposes them opens a pull request; the preset then holds the spec's status to what those links prove.

Adopting in an existing repository

Switching everything to error on a codebase that predates the rules blocks every PR at once. The practice that worked in Lore: start with the preset, put each noisy rule at warn, record the finding count next to it, and promote a rule to error in the same PR that drains its queue. A count in the config is the ratchet: it only moves down.

import tseslint from "typescript-eslint";
import reLint from "@re-cinq/eslint-plugin-re-lint";

export default tseslint.config(...reLint.configs.recommended({ tseslint }), {
  // Queue sizes on 2026-09-08: prefer-polymorphism 41, max-member-chain 17.
  // Each rule is promoted to "error" in the PR that empties its queue.
  rules: {
    "re-lint/prefer-polymorphism": "warn",
    "re-lint/max-member-chain": "warn",
  },
});

npx eslint . -f json | jq '[.[].messages[].ruleId] | group_by(.) | map({(.[0]): length}) | add' prints the queue sizes.

The Clean Code mapping

| Guideline | Enforced by | | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | Keep it simple; reduce complexity | complexity, max-depth, no-nested-if, no-nested-loop, max-boolean-operators, prefer-early-return | | Prefer polymorphism to if/else or switch | prefer-polymorphism | | Law of Demeter | max-member-chain, no-cross-layer-import, no-forwarding-class | | Use explanatory variables | max-boolean-operators | | Avoid negative conditionals | no-negated-condition, no-negative-names | | Descriptive, searchable, unencoded names | no-vague-names, id-length, naming-convention | | Replace magic numbers | no-magic-numbers | | Small functions that do one thing | max-lines-per-function, max-statements, max-lines | | Fewer arguments | max-params | | No side effects | no-param-reassign, no-prop-mutation, no-catch-as-control-flow | | No flag arguments | no-flag-params | | Comments: intent only, no noise, no closing-brace, no dead code | max-comment-lines, no-commented-out-code, no-closing-brace-comments | | Declare variables close to use; functions downward | declare-near-use, callee-below-caller | | Short lines, no alignment, vertical whitespace | Prettier, padding-line-between-statements | | Avoid hybrid structures; small objects | no-hybrid-class, max-classes-per-file | | Tests: one assert, independent, repeatable | max-expects, no-nondeterministic-tests, require-colocated-tests, test-imports-its-subject | | Needless repetition | no-duplicate-code |

Not lintable, left to code review: the boy scout rule, finding the root cause, encapsulating boundary conditions, avoiding logical dependency, hiding internal structure, base classes ignorant of their derivatives, and fast tests.

Contributing

npm ci
npm test          # builds dist/, then runs every RuleTester suite
npm run lint      # the package passes its own rules
npm run format

A rule is one file, src/rules/<name>.mjs, with its RuleTester suite beside it in <name>.test.mjs; helpers shared by several rules live in src/rules/lib/. src/rules/registration.test.mjs fails when a rule file is not exported from src/index.mjs, has no suite, or lacks meta.docs.description and a schema; src/index.test.mjs fails when a rule is neither in the preset nor listed as opt-in. Every config snippet in this README is a file under test-fixtures/readme/ that src/readme.test.mjs loads with the real ESLint and checks appears here verbatim.

Releasing

A GitHub Release is the release. The version comes from its tag: package.json carries the placeholder 0.0.0 on main and is never bumped, and the plugin reads meta.version from it at runtime, so no commit ever changes a version number.

  1. Land the changes on main as usual. A pull request that changes behaviour adds its CHANGELOG.md entry under the version it will ship in.

  2. On GitHub, draft a new release. Create a vX.Y.Z tag on main, generate the release notes, and publish it. Only a stable vX.Y.Z tag is accepted.

  3. Publishing the release starts the publish workflow. A first job rejects a malformed or prerelease tag in seconds. A second, read-only job repeats the CI gates at the tag: tests, lint, formatting and the tarball check. Only then does the third job, the only one holding the npm credential, stamp the tag's version into package.json, build, and stage the version on npm with provenance over OIDC. No token is stored in CI.

  4. Approve the stage. Until you do, the version sits on the registry and nobody can install it. Use the package's Versions tab on npmjs.com and approve with 2FA, or:

    npm stage list
    npm stage approve <id>

The trusted publisher grants staged publishing only, so no workflow run can make a version installable by itself. Publishing by hand bypasses that review and loses the provenance attestation with it.

A draft release never starts the workflow, and a pre-release is refused. To retry a failed publish, run the workflow by hand against the release's tag: from any other ref the tag check fails. A retry runs the workflow file as it was at the tagged commit, so if the fix is on main, delete the release and its tag and publish a fresh release from the new main. That is safe for any version the registry never accepted.

License

Apache-2.0