---
name: write-repo-lint-rule
description: Author project-local ESLint rules with eslint-plugin-repo-lint by dropping TypeScript files into a `.lints/` directory next to the root ESLint config. Use when the user wants to add, edit, or scaffold a custom lint rule in a repo that uses (or is about to use) eslint-plugin-repo-lint — e.g. "add a rule that bans X", "lint for this anti-pattern", "write a custom ESLint rule for our codebase".
license: MIT
metadata:
  package: eslint-plugin-repo-lint
  homepage: https://github.com/sophiabits/eslint-plugin-repo-lint
---

# Authoring repo-lint rules

`eslint-plugin-repo-lint` discovers project-local ESLint rules from a `.lints/` directory at the root of the repo (sibling to the ESLint config). Each file in `.lints/` becomes one rule, named after the filename.

## Process

### 1. Create `.lints/` next to the root ESLint config

If the repo doesn't already have a `.lints/` directory, create one **adjacent to the root `eslint.config.*` file** (the one ESLint actually loads — the first one walking up from the repo root). For legacy eslintrc setups, the anchor is the `.eslintrc.*` file with `root: true`.

```
repo-root/
├── eslint.config.js       # the root config
├── .lints/                # ← create this
│   └── ...
└── src/
```

Do not nest `.lints/` deeper than the root config — the loader anchors on the first flat config or `root: true` eslintrc walking up from `process.cwd()` and uses its sibling `.lints/`.

### 2. Wire the plugin into the ESLint config

Extend from `repo-lint/all` to make every discovered rule an error out of the box:

```js
// eslint.config.js (flat config, ESLint 9+)
const repoLint = require("eslint-plugin-repo-lint");

module.exports = [
  repoLint.configs["flat/all"],
];
```

Or opt-in per rule instead of `flat/all`:

```js
module.exports = [
  {
    plugins: { "repo-lint": require("eslint-plugin-repo-lint") },
    rules: {
      "repo-lint/<rule-name>": "error",
    },
  },
];
```

For legacy eslintrc (ESLint 8):

```yaml
# .eslintrc.yml
extends: ["plugin:repo-lint/all"]
```

### 3. Write the rule

Each file in `.lints/` is a single rule. The filename (minus `.ts`/`.js`) becomes the rule name — so `.lints/no-foo.ts` is exposed as `repo-lint/no-foo`.

The file must **default-export** a `TSESLint.RuleModule` — an object with a `create` function and a `meta` block. The loader throws at load time if the default export doesn't look like a `RuleModule`.

```ts
// .lints/no-todo-comment.ts
import type { TSESLint } from "@typescript-eslint/utils";

const rule: TSESLint.RuleModule<"noTodo", []> = {
  meta: {
    type: "suggestion",
    schema: [],
    messages: {
      noTodo: "Resolve or file a ticket — don't leave TODO comments.",
    },
  },
  defaultOptions: [],
  create(context) {
    return {
      Program() {
        for (const comment of context.sourceCode.getAllComments()) {
          if (/\bTODO\b/.test(comment.value)) {
            context.report({ node: comment, messageId: "noTodo" });
          }
        }
      },
    };
  },
};

export default rule;
```

## Filename conventions (loader behavior)

The loader iterates `.lints/` and applies these rules:

| Pattern              | Treated as                            |
| -------------------- | ------------------------------------- |
| `foo.ts` / `foo.js`  | Rule (registered as `repo-lint/foo`)  |
| `foo.test.ts`        | Test file — skipped                   |
| `foo.spec.ts`        | Test file — skipped                   |
| `_helper.ts`         | Shared helper — skipped               |
| `.hidden.ts`         | Dotfile — skipped                     |
| `index.ts`           | Aggregation file — skipped            |
| `foo.ts` AND `foo.js`| Error: ambiguous, pick one extension  |

Nested directories under `.lints/` are not supported — one file per rule, flat layout.

## Co-locating tests

Put rule tests next to the rule as `<rule>.test.ts` (or `.spec.ts`). They're skipped by the loader but picked up by Vitest / Jest / `node --test`. Use `@typescript-eslint/rule-tester`:

```ts
// .lints/no-todo-comment.test.ts
import { RuleTester } from "@typescript-eslint/rule-tester";

import rule from "./no-todo-comment";

new RuleTester().run("no-todo-comment", rule, {
  valid: ["const x = 1;"],
  invalid: [
    {
      code: "// TODO: fix this",
      errors: [{ messageId: "noTodo" }],
    },
  ],
});
```

`RuleTester` expects `describe` / `it` / `afterAll` as globals (Vitest globals, Jest defaults, or assign them manually when using `node:test`).

## Common pitfalls

- **Forgot `export default`.** A `RuleModule` exported as a named export (`export const rule = ...`) is not picked up — the loader checks the default export. The plugin throws a clear error at load time.
- **Missing `meta.messages`.** If `create()` calls `context.report({ messageId: "foo" })` but `meta.messages.foo` isn't declared, ESLint throws at lint time (not load time). The loader's structural check only validates `create` is a function.
- **Type-only correctness.** The loader uses sucrase to strip types without type-checking. To catch type errors in your rules, run `tsc --noEmit` against `.lints/` (typically in CI).
- **Security.** `.lints/` files are `require()`d as code in CI and on contributor machines. Treat the directory like any other source path; for repos that accept PRs from forks, gate `.lints/` changes behind `CODEOWNERS`.

## After writing the rule

1. Restart the ESLint server in the editor (or re-run ESLint from CLI) to load the new rule.
2. Verify the rule appears in `repo-lint/<filename>` — running `npx eslint --print-config <some-file>` will show it under `rules`.
3. Add or update the co-located `<rule>.test.ts` to cover both `valid` and `invalid` cases.
