diff --git a/.changeset/no-zod-enum-rule.md b/.changeset/no-zod-enum-rule.md new file mode 100644 index 0000000..f3cfbd6 --- /dev/null +++ b/.changeset/no-zod-enum-rule.md @@ -0,0 +1,5 @@ +--- +'@gaia-react/lint': minor +--- + +Add the `no-zod-enum` guardrail: `z.enum(...)` is now an `error` in `.ts`/`.tsx`. Use `z.literal([...])` for string unions (sort values alphanumerically). Report-only (no autofix), since `z.enum`'s `.enum`/`.options` accessors have no `z.literal` array equivalent and a rename can't sort. Consumers with existing `z.enum()` will see a new lint error on upgrade. diff --git a/README.md b/README.md index 6dffd63..0363496 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ new source root — no per-config override blocks needed. | `base` | `Linter.Config[]` | JS recommended, TypeScript (typescript-eslint), `import-x`, `eslint-comments`, `prefer-arrow-functions`, lodash/underscore guard | required | | `react` | `Linter.Config[]` | `eslint-plugin-react`, `react-hooks`, `jsx-a11y`, GAIA-specific React rules | required for React apps | | `styleHygiene` | `Linter.Config[]` | `canonical`, `perfectionist`, `unicorn`, `unused-imports`, `check-file` | required | -| `guardrails` | `Linter.Config[]` | `no-enum` (custom), `no-switch` (custom), `no-jsx-iife` (custom), `no-null-render` (custom), `no-relative-import-paths`, `sonarjs`, `eslint-comments`, `import-x`, `prefer-arrow-functions` | required | +| `guardrails` | `Linter.Config[]` | `no-enum` (custom), `no-switch` (custom), `no-jsx-iife` (custom), `no-null-render` (custom), `no-zod-enum` (custom), `no-relative-import-paths`, `sonarjs`, `eslint-comments`, `import-x`, `prefer-arrow-functions` | required | | `testing` | `Linter.Config[]` | Vitest + Testing Library config scoped to `*.test.*` and `test/` | optional | | `storybook` | `Linter.Config[]` | `eslint-plugin-storybook` scoped to `*.stories.*` | optional | | `playwright` | `Linter.Config[]` | `eslint-plugin-playwright` scoped to `e2e/` | optional | diff --git a/src/configs/guardrails.ts b/src/configs/guardrails.ts index c8fd7a0..b587b83 100644 --- a/src/configs/guardrails.ts +++ b/src/configs/guardrails.ts @@ -5,6 +5,7 @@ import noEnumPlugin from '../plugins/no-enum.js'; import noJsxIifePlugin from '../plugins/no-jsx-iife.js'; import noNullRenderPlugin from '../plugins/no-null-render.js'; import noSwitchPlugin from '../plugins/no-switch.js'; +import noZodEnumPlugin from '../plugins/no-zod-enum.js'; const buildSonarConfig = (sourceDir: string): Linter.Config[] => [ sonarjs.configs!.recommended as Linter.Config, @@ -85,6 +86,15 @@ const noSwitchConfig: Linter.Config[] = [ }, ]; +const noZodEnumConfig: Linter.Config[] = [ + { + files: ['**/*.ts?(x)'], + name: 'no-zod-enum', + plugins: {'no-zod-enum': noZodEnumPlugin}, + rules: {'no-zod-enum/no-zod-enum': 'error'}, + }, +]; + /** * Architecture-boundary enforcement for GAIA's canonical `app/` layout. * @@ -228,6 +238,7 @@ export const buildGuardrails = (sourceDir: string): Linter.Config[] => [ ...noJsxIifeConfig, ...noNullRenderConfig, ...noSwitchConfig, + ...noZodEnumConfig, ...buildNoRestrictedPathsConfig(sourceDir), ...buildNoRelativeImportPathsConfig(sourceDir), ]; diff --git a/src/plugins/no-zod-enum.test.ts b/src/plugins/no-zod-enum.test.ts new file mode 100644 index 0000000..4f59873 --- /dev/null +++ b/src/plugins/no-zod-enum.test.ts @@ -0,0 +1,54 @@ +/** + * RuleTester suite for the `no-zod-enum` rule. + * + * `z.enum([...])` parses under default espree: the rule only inspects + * CallExpression / MemberExpression / Identifier nodes, so no TS parser is + * needed. The rule is report-only (no autofix), so no `output` is asserted. + */ +import {RuleTester} from 'eslint'; +import {describe, it} from 'vitest'; +import noZodEnumPlugin from './no-zod-enum.js'; + +// Route RuleTester's case registration through vitest for per-case reporting. +RuleTester.describe = describe; +RuleTester.it = it; + +const noZodEnumRule = noZodEnumPlugin.rules!['no-zod-enum']; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, +}); + +ruleTester.run('no-zod-enum', noZodEnumRule, { + // --- VALID: must NOT be flagged --- + valid: [ + // 1. The prescribed replacement. + "const schema = z.literal(['a', 'b']);", + // 2. Any other z.* method is untouched. + 'const schema = z.string();', + // 3. Property access (not a call) on a non-z object. + 'const value = Color.enum;', + // 4. `.enum()` on a different namespace: not the z convention. + "const schema = other.enum(['a', 'b']);", + // 5. Computed access `z['enum'](...)`: documented out of scope (property is + // a Literal, not an Identifier named `enum`). + "const schema = z['enum'](['a', 'b']);", + ], + + // --- INVALID: must be flagged --- + invalid: [ + // 6. The canonical violation: an inline string-union enum. + { + code: "const schema = z.enum(['a', 'b']);", + errors: [{messageId: 'noZodEnum'}], + }, + // 7. z.enum over a referenced const is still flagged. + { + code: 'const schema = z.enum(Colors);', + errors: [{messageId: 'noZodEnum'}], + }, + ], +}); diff --git a/src/plugins/no-zod-enum.ts b/src/plugins/no-zod-enum.ts new file mode 100644 index 0000000..8ad4956 --- /dev/null +++ b/src/plugins/no-zod-enum.ts @@ -0,0 +1,31 @@ +import type {ESLint, Rule} from 'eslint'; + +const noZodEnumRule: Rule.RuleModule = { + create: (context) => ({ + 'CallExpression > MemberExpression.callee[object.name="z"][property.name="enum"]': + (node: Rule.Node) => { + context.report({messageId: 'noZodEnum', node}); + }, + }), + meta: { + docs: {description: 'Disallow z.enum(); use z.literal([...]) instead'}, + messages: { + noZodEnum: + 'Do not use z.enum(). Use z.literal([...]) for string unions (sort values alphanumerically).', + }, + schema: [], + type: 'problem', + }, +}; + +const plugin: ESLint.Plugin = { + meta: { + name: 'no-zod-enum', + version: '0.1.0', + }, + rules: { + 'no-zod-enum': noZodEnumRule, + }, +}; + +export default plugin;