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