UNPKG

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
--- 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.