eslint-plugin-repo-lint
Version:
Drop-in ESLint plugin that loads TypeScript rule files from a conventional .lints/ directory without a build step.
149 lines (113 loc) • 5.81 kB
Markdown
---
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.