eslint-plugin-repo-lint
Version:
Drop-in ESLint plugin that loads TypeScript rule files from a conventional .lints/ directory without a build step.
132 lines (97 loc) • 3.22 kB
Markdown
# eslint-plugin-repo-lint
Add project-specific ESLint rules to a .lints/ folder. No build step, no plugin scaffolding. Great for cases [ast-grep](https://github.com/ast-grep/ast-grep) can't express.
## Install
```sh
pnpm install --save-dev eslint-plugin-repo-lint
```
If you're writing rules in TypeScript, also install `@typescript-eslint/utils`:
```sh
pnpm install --save-dev @typescript-eslint/utils
```
## Use
Flat config (ESLint 9+):
```js
// eslint.config.js
const repoLint = require("eslint-plugin-repo-lint");
module.exports = [repoLint.configs["flat/all"]];
```
Or `eslint.config.mjs`:
```js
import repoLint from "eslint-plugin-repo-lint";
export default [repoLint.configs["flat/all"]];
```
Opt in per rule instead of `flat/all`:
```js
module.exports = [
{
plugins: { "repo-lint": require("eslint-plugin-repo-lint") },
rules: {
// requirement: you have a `.lints/sentry-tags-snake-case.{js,ts}` file
"repo-lint/sentry-tags-snake-case": "error",
},
},
];
```
Legacy eslintrc (ESLint 8):
```yaml
# .eslintrc.yml
extends: ["plugin:repo-lint/all"]
```
## Write a rule
Drop a TypeScript file in `.lints/`. The filename becomes the rule name.
```ts
// .lints/sentry-tags-snake-case.ts
import type { TSESLint, TSESTree } from "@typescript-eslint/utils";
const rule: TSESLint.RuleModule<"snakeCase", []> = {
meta: {
type: "problem",
fixable: "code",
schema: [],
messages: {
snakeCase: "Tag '{{key}}' should be snake_case (suggested: '{{fixed}}').",
},
},
defaultOptions: [],
create(context) {
return {
CallExpression(node: TSESTree.CallExpression) {
// ...rule logic...
},
};
},
};
export default rule;
```
`.lints/sentry-tags-snake-case.ts` → `repo-lint/sentry-tags-snake-case`.
Plain JS files (`.js`) work too — they skip the transpile step.
## Test a rule
Co-locate the test as `<rule>.test.ts` next to the rule. The loader ignores `.test.` and `.spec.` files, so it won't try to register the test as a rule.
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { globals: true },
});
```
```ts
// .lints/sentry-tags-snake-case.test.ts
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule from "./sentry-tags-snake-case";
new RuleTester().run("sentry-tags-snake-case", rule, {
valid: ["Sentry.setTag('user_id', '42');"],
invalid: [
{
code: "Sentry.setTag('userId', '42');",
errors: [{ messageId: "snakeCase" }],
},
],
});
```
Run:
```sh
pnpx vitest run .lints
```
## Notes
1. ESLint editor extensions typically cache loaded plugin modules. After adding or editing a rule, you might need to restart the ESLint server for your updated rule to get picked up by your editor.
2. Rules written in TypeScript are not type-checked. Run `tsc --noEmit` against your `.lints/` folder separately if you want to check types.
3. The rule loader anchors on the first `eslint.config.*` (flat config) or `.eslintrc.*` with `root: true` it finds walking up from ESLint's working directory. The walk-up logic stops when it encounters a `.git` directory to try prevent breaking out of a repo boundary.