diff --git a/.changeset/config.json b/.changeset/config.json index f31b9944a..162dc42a8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,7 +8,8 @@ "@openapi-qraft/plugin", "@openapi-qraft/tanstack-query-react-plugin", "@openapi-qraft/react", - "@openapi-qraft/tanstack-query-react-types" + "@openapi-qraft/tanstack-query-react-types", + "@openapi-qraft/eslint-plugin-query" ] ], "linked": [], diff --git a/.changeset/wise-cats-stare.md b/.changeset/wise-cats-stare.md new file mode 100644 index 000000000..01421876c --- /dev/null +++ b/.changeset/wise-cats-stare.md @@ -0,0 +1,7 @@ +--- +'@openapi-qraft/eslint-plugin-query': minor +--- + +Added `@openapi-qraft/eslint-plugin-query` + +An ESLint plugin to enforce best practices and prevent common mistakes with Qraft-generated hooks has been added. diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 4d960377c..e07b029fe 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -2,13 +2,13 @@ amannn APITS apps Batalov -Bcategory bccc bnpm bnpx bytesin cbb cdn +clientapi codegen codemod csr @@ -24,17 +24,18 @@ expressjs fba fddbf Firefox +gimuy gitlab groupnames hipchat htpasswd ianvs iat -iddle idx ietf ifm INADDR +Intelli iss JOPT jscodeshift @@ -43,11 +44,12 @@ jwt kubb linkcheck linting -mnot monite mozilla mryechkin mycdn +myclient +myqraft nbf nochange npmrc @@ -61,8 +63,10 @@ petstore pids QMethod qraft +qraftclient qraftio radist +reate redocly refetched Refetches @@ -75,6 +79,7 @@ somedomain srv ssr swcrc +TAdditional tagline tarball TAsync @@ -93,6 +98,7 @@ TRequest TResult TSchema tservice +TSES TSignal TValue TVariables @@ -113,6 +119,3 @@ workaround workflows workspaces XMonite -TCallbacks -reate -Intelli \ No newline at end of file diff --git a/knip.json b/knip.json index 411b7be55..55f828981 100644 --- a/knip.json +++ b/knip.json @@ -85,6 +85,13 @@ ], "ignore": ["src/**/__snapshots__/**", "eslint.config.js"] }, + "packages/eslint-plugin-query": { + "project": [ + "src/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}", + "!src/__tests__/**" + ], + "ignore": ["rollup.config.mjs", "eslint.config.js"] + }, "playground": { "ignoreDependencies": ["dotenv", "dotenv-expand", "eslint-plugin-react", "@tanstack/react-query"] } diff --git a/packages/eslint-config/eslint.vanilla.config.js b/packages/eslint-config/eslint.vanilla.config.js index a689214a7..563e8c28a 100644 --- a/packages/eslint-config/eslint.vanilla.config.js +++ b/packages/eslint-config/eslint.vanilla.config.js @@ -30,6 +30,7 @@ export default [ files: [ '**/**.spec.{ts,tsx}', '**/**.test.{ts,tsx}', + '**/__tests__/**', '**/setupTests.ts', '**/vitestFsMock.ts', ], diff --git a/packages/eslint-plugin-query/README.md b/packages/eslint-plugin-query/README.md new file mode 100644 index 000000000..a3724c3b4 --- /dev/null +++ b/packages/eslint-plugin-query/README.md @@ -0,0 +1,131 @@ +# @openapi-qraft/eslint-plugin-query + +ESLint plugin for projects using `@openapi-qraft/react`. It helps enforce best practices around Qraft-generated hooks (e.g. `qraft.service.operation.useQuery()`, `qraft.useInfiniteQuery()`) and prevents mistakes that cause unnecessary re-renders or unstable dependencies. + +- Homepage: `https://openapi-qraft.github.io/openapi-qraft/docs/eslint/eslint-plugin-query` +- Repository: `https://github.com/OpenAPI-Qraft/openapi-qraft` + +## Requirements + +- Node.js >= 18 +- ESLint ^8.57.0 or ^9.0.0 + +## Installation + +```bash +npm i -D @openapi-qraft/eslint-plugin-query +# or +pnpm add -D @openapi-qraft/eslint-plugin-query +# or +yarn add -D @openapi-qraft/eslint-plugin-query +# or +bun add -D @openapi-qraft/eslint-plugin-query +``` + +## Usage (ESLint Flat Config) + +Create or update `eslint.config.js`: + +```js +import pluginQraftQuery from '@openapi-qraft/eslint-plugin-query' + +export default [ + // Enable the recommended rules for this plugin + ...pluginQraftQuery.configs['flat/recommended'], + // Any other config... +] +``` + +### Custom setup (Flat Config) + +```js +import pluginQraftQuery from '@openapi-qraft/eslint-plugin-query' + +export default [ + { + plugins: { + '@openapi-qraft/query': pluginQraftQuery, + }, + rules: { + '@openapi-qraft/query/no-rest-destructuring': 'warn', + '@openapi-qraft/query/no-unstable-deps': 'error', + }, + }, +] +``` + +## Usage (Legacy config) + +Add to your `.eslintrc`: + +```json +{ + "extends": [ + "plugin:@openapi-qraft/query/recommended" + ] +} +``` + +### Custom setup (Legacy) + +```json +{ + "plugins": ["@openapi-qraft/query"], + "rules": { + "@openapi-qraft/query/no-rest-destructuring": "warn", + "@openapi-qraft/query/no-unstable-deps": "error" + } +} +``` + +## Options + +Some rules detect Qraft-generated hooks only when they are called as methods on your client instance (e.g. `qraft.service.operation.useQuery()`). +To support custom client names, each rule accepts an optional `clientNamePattern` (string or regex literal) option. By default, it matches the case-insensitive regex `/qraft|api/i`. + +Example (Flat Config): + +```js +import pluginQraftQuery from '@openapi-qraft/eslint-plugin-query' + +export default [ + { + plugins: { '@openapi-qraft/query': pluginQraftQuery }, + rules: { + '@openapi-qraft/query/no-rest-destructuring': [ + 'warn', + { clientNamePattern: '/myQraftClient/i' }, + ], + '@openapi-qraft/query/no-unstable-deps': [ + 'error', + { clientNamePattern: '/myQraftClient/i' }, + ], + }, + }, +] +``` + +## Configurations + +This plugin ships with the following shareable configurations: + +- `plugin:@openapi-qraft/query/recommended` (Legacy) +- `plugin.configs['flat/recommended']` (Flat) + +They enable the rules as follows: + +- `@openapi-qraft/query/no-rest-destructuring`: warn +- `@openapi-qraft/query/no-unstable-deps`: error + +## Rules + +- `@openapi-qraft/query/no-rest-destructuring` — Disallow object rest destructuring on query results. Recommended: warn. Docs: `https://openapi-qraft.github.io/openapi-qraft/docs/eslint/no-rest-destructuring` +- `@openapi-qraft/query/no-unstable-deps` — Disallow putting the result of query hooks directly in a React hook dependency array. Recommended: error. Docs: `https://openapi-qraft.github.io/openapi-qraft/docs/eslint/no-unstable-deps` + +## TypeScript + +This plugin is implemented with `@typescript-eslint/utils` and works in both JavaScript and TypeScript projects. No special TypeScript configuration is required beyond your project's usual setup. + +## License + +MIT © OpenAPI Qraft diff --git a/packages/eslint-plugin-query/eslint.config.js b/packages/eslint-plugin-query/eslint.config.js new file mode 100644 index 000000000..d8f66c5f2 --- /dev/null +++ b/packages/eslint-plugin-query/eslint.config.js @@ -0,0 +1,7 @@ +import openAPIQraftConfig from '@openapi-qraft/eslint-config/eslint.vanilla.config'; +import globals from 'globals'; + +export default [ + { languageOptions: { globals: globals.node } }, + ...openAPIQraftConfig, +]; diff --git a/packages/eslint-plugin-query/package.json b/packages/eslint-plugin-query/package.json new file mode 100644 index 000000000..9e519b659 --- /dev/null +++ b/packages/eslint-plugin-query/package.json @@ -0,0 +1,67 @@ +{ + "name": "@openapi-qraft/eslint-plugin-query", + "version": "2.11.0", + "description": "ESLint plugin for OpenAPI Qraft", + "homepage": "https://openapi-qraft.github.io/openapi-qraft/docs/eslint/eslint-plugin-query", + "packageManager": "yarn@4.0.2", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/OpenAPI-Qraft/openapi-qraft.git", + "directory": "packages/eslint-plugin-query" + }, + "scripts": { + "build": "yarn clean && NODE_ENV=production rollup --config rollup.config.mjs && tsc --project tsconfig.build.json --emitDeclarationOnly", + "typecheck": "tsc --noEmit", + "lint": "eslint", + "test": "vitest run --coverage", + "clean": "rimraf dist/" + }, + "type": "module", + "types": "dist/types/index.d.ts", + "module": "dist/esm/index.js", + "main": "dist/cjs/index.cjs", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.cjs" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "*": [ + "dist/types/*" + ] + } + }, + "sideEffects": false, + "files": [ + "dist", + "src", + "!src/__tests__" + ], + "dependencies": { + "@typescript-eslint/utils": "^8.37.0" + }, + "devDependencies": { + "@openapi-qraft/eslint-config": "workspace:*", + "@openapi-qraft/rollup-config": "workspace:*", + "@types/node": "^20.16.5", + "@typescript-eslint/rule-tester": "^8.37.0", + "@typescript-eslint/typescript-estree": "^8.39.1", + "@vitest/coverage-v8": "^3.0.5", + "eslint": "^9.24.0", + "rimraf": "^5.0.10", + "rollup": "~4.22.4", + "typescript": "^5.6.2", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } +} diff --git a/packages/eslint-plugin-query/rollup.config.mjs b/packages/eslint-plugin-query/rollup.config.mjs new file mode 100644 index 000000000..c006a0703 --- /dev/null +++ b/packages/eslint-plugin-query/rollup.config.mjs @@ -0,0 +1,11 @@ +import { rollupConfig } from '@openapi-qraft/rollup-config'; +import packageJson from './package.json' with { type: 'json' }; + +const moduleDist = { + import: packageJson['exports']['.']['import'], + require: packageJson['exports']['.']['require'], +}; + +const config = [rollupConfig(moduleDist, { input: 'src/index.ts' })]; + +export default config; diff --git a/packages/eslint-plugin-query/src/__tests__/create-client-name-regex.test.ts b/packages/eslint-plugin-query/src/__tests__/create-client-name-regex.test.ts new file mode 100644 index 000000000..3bfc15fa2 --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/create-client-name-regex.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'vitest'; +import { createClientNameRegex } from '../utils/create-client-name-regex'; + +describe('createClientNameRegex', () => { + describe('default behavior', () => { + test('should return default "/qraft|api/i" pattern when no pattern provided', () => { + const regex = createClientNameRegex(undefined); + expect(regex.source).toBe('qraft|api'); + expect(regex.flags).toBe('i'); + }); + + test('should throw error when empty string provided', () => { + expect(() => createClientNameRegex('')).toThrow( + 'clientNamePattern cannot be empty or contain only whitespace' + ); + }); + + test('should throw error when whitespace-only string provided', () => { + expect(() => createClientNameRegex(' ')).toThrow( + 'clientNamePattern cannot be empty or contain only whitespace' + ); + }); + + test('should throw error when tabs and spaces only provided', () => { + expect(() => createClientNameRegex('\t \n ')).toThrow( + 'clientNamePattern cannot be empty or contain only whitespace' + ); + }); + }); + + describe('plain string patterns', () => { + test('should create regex from plain string', () => { + const regex = createClientNameRegex('api'); + expect(regex.source).toBe('api'); + expect(regex.flags).toBe(''); + }); + + test('should handle special regex characters in plain string', () => { + const regex = createClientNameRegex('api.client'); + expect(regex.source).toBe('api.client'); + expect(regex.flags).toBe(''); + }); + + test('should handle complex plain string patterns', () => { + const regex = createClientNameRegex('^myClient$'); + expect(regex.source).toBe('^myClient$'); + expect(regex.flags).toBe(''); + }); + }); + + describe('regex format patterns', () => { + test('should parse regex with case insensitive flag', () => { + const regex = createClientNameRegex('/api/i'); + expect(regex.source).toBe('api'); + expect(regex.flags).toBe('i'); + }); + + test('should parse regex with global flag', () => { + const regex = createClientNameRegex('/qraft/g'); + expect(regex.source).toBe('qraft'); + expect(regex.flags).toBe('g'); + }); + + test('should parse regex with multiple flags', () => { + const regex = createClientNameRegex('/client/gim'); + expect(regex.source).toBe('client'); + expect(regex.flags).toBe('gim'); + }); + + test('should parse regex with all supported flags', () => { + const regex = createClientNameRegex('/test/gimuy'); + expect(regex.source).toBe('test'); + expect(regex.flags).toBe('gimuy'); + }); + + test('should parse complex regex patterns', () => { + const regex = createClientNameRegex('/^api.*client$/i'); + expect(regex.source).toBe('^api.*client$'); + expect(regex.flags).toBe('i'); + }); + + test('should parse regex with escaped forward slashes', () => { + const regex = createClientNameRegex('/api\\/v1/i'); + expect(regex.source).toBe('api\\/v1'); + expect(regex.flags).toBe('i'); + }); + }); + + describe('edge cases', () => { + test('should treat malformed regex as plain string', () => { + const regex = createClientNameRegex('/api'); + expect(regex.source).toBe('\\/api'); // Forward slashes are escaped in RegExp.source + expect(regex.flags).toBe(''); + }); + + test('should treat regex without closing slash as plain string', () => { + const regex = createClientNameRegex('/api/i/extra'); + expect(regex.source).toBe('\\/api\\/i\\/extra'); // Forward slashes are escaped in RegExp.source + expect(regex.flags).toBe(''); + }); + + test('should throw error for empty regex pattern', () => { + expect(() => createClientNameRegex('//i')).toThrow( + 'clientNamePattern cannot be empty or contain only whitespace' + ); + }); + + test('should throw error for whitespace-only regex pattern', () => { + expect(() => createClientNameRegex('/ /g')).toThrow( + 'clientNamePattern cannot be empty or contain only whitespace' + ); + }); + + test('should handle regex with no flags', () => { + const regex = createClientNameRegex('/api/'); + expect(regex.source).toBe('api'); + expect(regex.flags).toBe(''); + }); + + test('should reject invalid flags', () => { + const regex = createClientNameRegex('/api/x'); + expect(regex.source).toBe('\\/api\\/x'); // Forward slashes are escaped in RegExp.source + expect(regex.flags).toBe(''); + }); + }); + + describe('functionality tests', () => { + test('should match correctly with case insensitive flag', () => { + const regex = createClientNameRegex('/api/i'); + expect(regex.test('API')).toBe(true); + expect(regex.test('api')).toBe(true); + expect(regex.test('Api')).toBe(true); + }); + + test('should match correctly with anchors', () => { + const regex = createClientNameRegex('/^qraft$/'); + expect(regex.test('qraft')).toBe(true); + expect(regex.test('myqraft')).toBe(false); + expect(regex.test('qraftclient')).toBe(false); + }); + + test('should match correctly with plain string pattern', () => { + const regex = createClientNameRegex('client'); + expect(regex.test('client')).toBe(true); + expect(regex.test('myclient')).toBe(true); + expect(regex.test('clientapi')).toBe(true); + expect(regex.test('api')).toBe(false); + }); + }); +}); diff --git a/packages/eslint-plugin-query/src/__tests__/get-root-identifier.test.ts b/packages/eslint-plugin-query/src/__tests__/get-root-identifier.test.ts new file mode 100644 index 000000000..e9934447f --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/get-root-identifier.test.ts @@ -0,0 +1,45 @@ +import { parse } from '@typescript-eslint/typescript-estree'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { describe, expect, test } from 'vitest'; +import { getRootIdentifier } from '../utils/get-root-identifier'; + +describe('get-root-identifier', () => { + test('returns Identifier for nested MemberExpression chain', () => { + // Represents: a.b.c + const ast = parse('a.b.c'); + const exprStmt = ast.body[0]; + const expr = (exprStmt as any).expression; // MemberExpression + const result = getRootIdentifier(expr); + expect(result?.type).toBe(AST_NODE_TYPES.Identifier); + expect(result?.name).toBe('a'); + }); + + test('unwraps TSNonNullExpression before MemberExpression', () => { + // Represents: a!.b + const ast = parse('a!.b'); + const exprStmt = ast.body[0]; + const expr = (exprStmt as any).expression; // MemberExpression(TSNonNullExpression a, b) + const result = getRootIdentifier(expr); + expect(result?.type).toBe(AST_NODE_TYPES.Identifier); + expect(result?.name).toBe('a'); + }); + + test('handles nested ChainExpression wrappers', () => { + // Represents: (a?.b)?.c + const ast = parse('(a?.b)?.c'); + const exprStmt = ast.body[0]; + const expr = (exprStmt as any).expression; // ChainExpression + const result = getRootIdentifier(expr); + expect(result?.type).toBe(AST_NODE_TYPES.Identifier); + expect(result?.name).toBe('a'); + }); + + test('returns undefined when root is not Identifier', () => { + // Represents: (42).x — the left-most node is a Literal, not an Identifier + const ast = parse('(42).x'); + const exprStmt = ast.body[0]; + const expr = (exprStmt as any).expression; // MemberExpression + const result = getRootIdentifier(expr); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts b/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts new file mode 100644 index 000000000..a2b8eea12 --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/no-rest-destructuring.test.ts @@ -0,0 +1,415 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { rule } from '../rules/no-rest-destructuring/no-rest-destructuring.rule'; +import { normalizeIndent } from './test-utils'; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-rest-destructuring', rule, { + valid: [ + { + name: 'useQuery is not captured', + code: normalizeIndent` + function Component() { + qraft.service.operation.useQuery() + return + } + `, + }, + { + name: 'useQuery is not captured', + code: normalizeIndent` + function Component() { + qraft.operation.useQuery() + return + } + `, + }, + { + name: 'useQuery is not captured', + code: normalizeIndent` + function Component() { + qraft.useQuery() + return + } + `, + }, + { + name: 'useQuery is not destructured', + code: normalizeIndent` + function Component() { + const query = qraft.service.operation.useQuery() + return + } + `, + }, + { + name: 'useQuery is not destructured', + code: normalizeIndent` + function Component() { + const query = qraft.operation.useQuery() + return + } + `, + }, + { + name: 'useQuery is not destructured', + code: normalizeIndent` + function Component() { + const query = qraft.useQuery() + return + } + `, + }, + { + name: 'useQuery is destructured without rest', + code: normalizeIndent` + function Component() { + const { data, isLoading, isError } = qraft.service.operation.useQuery() + return + } + `, + }, + { + name: 'useQuery is destructured without rest', + code: normalizeIndent` + function Component() { + const { data, isLoading, isError } = qraft.operation.useQuery() + return + } + `, + }, + { + name: 'useQuery is destructured without rest', + code: normalizeIndent` + function Component() { + const { data, isLoading, isError } = qraft.useQuery() + return + } + `, + }, + { + name: 'useInfiniteQuery is not captured', + code: normalizeIndent` + function Component() { + qraft.service.operation.useInfiniteQuery() + return + } + `, + }, + { + name: 'useInfiniteQuery is not captured', + code: normalizeIndent` + function Component() { + qraft.useInfiniteQuery() + return + } + `, + }, + { + name: 'useInfiniteQuery is not destructured', + code: normalizeIndent` + function Component() { + const query = qraft.service.operation.useInfiniteQuery() + return + } + `, + }, + { + name: 'useInfiniteQuery is destructured without rest', + code: normalizeIndent` + function Component() { + const { data, isLoading, isError } = qraft.useInfiniteQuery() + return + } + `, + }, + { + name: 'useQueries is not captured', + code: normalizeIndent` + function Component() { + qraft.service.operation.useQueries([]) + return + } + `, + }, + { + name: 'useQueries is not destructured', + code: normalizeIndent` + function Component() { + const queries = qraft.useQueries([]) + return + } + `, + }, + { + name: 'useQueries array has no rest destructured element', + code: normalizeIndent` + function Component() { + const [query1, { data, isLoading },, ...others] = qraft.service.operation.useQueries([ + { queryKey: ['key1'], queryFn: () => {} }, + { queryKey: ['key2'], queryFn: () => {} }, + { queryKey: ['key3'], queryFn: () => {} }, + { queryKey: ['key4'], queryFn: () => {} }, + { queryKey: ['key5'], queryFn: () => {} }, + ]) + return + } + `, + }, + { + name: 'useQuery is destructured with rest but not from Qraft', + code: normalizeIndent` + function Component() { + const { data, ...rest } = otherLibrary.useQuery() + return + } + `, + }, + { + name: 'useInfiniteQuery is destructured with rest but not from Qraft', + code: normalizeIndent` + function Component() { + const { data, ...rest } = otherLibrary.useInfiniteQuery() + return + } + `, + }, + { + name: 'useQueries array has rest destructured element but not from Qraft', + code: normalizeIndent` + import { useQueries } from 'other-package' + + function Component() { + const [query1, { data, ...rest }] = otherLibrary.useQueries([ + { queryKey: ['key1'], queryFn: () => {} }, + { queryKey: ['key2'], queryFn: () => {} }, + ]) + return + } + `, + }, + { + name: 'useSuspenseQuery is not captured', + code: normalizeIndent` + function Component() { + qraft.service.operation.useSuspenseQuery() + return + } + `, + }, + { + name: 'useSuspenseQuery is not destructured', + code: normalizeIndent` + function Component() { + const query = qraft.useSuspenseQuery() + return + } + `, + }, + { + name: 'useSuspenseQuery is destructured without rest', + code: normalizeIndent` + function Component() { + const { data, isLoading, isError } = qraft.service.operation.useSuspenseQuery() + return + } + `, + }, + { + name: 'useSuspenseInfiniteQuery is not captured', + code: normalizeIndent` + function Component() { + qraft.service.operation.useSuspenseInfiniteQuery() + return + } + `, + }, + { + name: 'useSuspenseInfiniteQuery is not destructured', + code: normalizeIndent` + function Component() { + const query = qraft.useSuspenseInfiniteQuery() + return + } + `, + }, + { + name: 'useSuspenseInfiniteQuery is destructured without rest', + code: normalizeIndent` + function Component() { + const { data, isLoading, isError } = qraft.service.operation.useSuspenseInfiniteQuery() + return + } + `, + }, + { + name: 'useSuspenseQueries is not captured', + code: normalizeIndent` + function Component() { + qraft.useSuspenseQueries([]) + return + } + `, + }, + { + name: 'useSuspenseQueries is not destructured', + code: normalizeIndent` + function Component() { + const queries = qraft.service.operation.useSuspenseQueries([]) + return + } + `, + }, + { + name: 'useSuspenseQueries array has no rest destructured element', + code: normalizeIndent` + function Component() { + const [query1, { data, isLoading }] = qraft.service.operation.useSuspenseQueries([ + { queryKey: ['key1'], queryFn: () => {} }, + { queryKey: ['key2'], queryFn: () => {} }, + ]) + return + } + `, + }, + { + name: 'useSuspenseQuery is destructured with rest but not from Qraft', + code: normalizeIndent` + function Component() { + const { data, ...rest } = otherLibrary.useSuspenseQuery() + return + } + `, + }, + { + name: 'useSuspenseInfiniteQuery is destructured with rest but not from Qraft', + code: normalizeIndent` + function Component() { + const { data, ...rest } = otherLibrary.useSuspenseInfiniteQuery() + return + } + `, + }, + { + name: 'useSuspenseQueries array has rest destructured element but not from Qraft', + code: normalizeIndent` + function Component() { + const [query1, { data, ...rest }] = otherLibrary.useSuspenseQueries([ + { queryKey: ['key1'], queryFn: () => {} }, + { queryKey: ['key2'], queryFn: () => {} }, + ]) + return + } + `, + }, + ], + invalid: [ + { + name: 'useQuery is destructured with rest', + code: normalizeIndent` + function Component() { + const { data, ...rest } = qraft.service.operation.useQuery() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useQuery is destructured with rest', + code: normalizeIndent` + function Component() { + const { data, ...rest } = qraft.operation.useQuery() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useQuery is destructured with rest', + code: normalizeIndent` + function Component() { + const { data, ...rest } = qraft.useQuery() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useInfiniteQuery is destructured with rest', + code: normalizeIndent` + function Component() { + const { data, ...rest } = qraft.service.operation.useInfiniteQuery() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useQueries array has rest destructured element', + code: normalizeIndent` + function Component() { + const [query1, { data, ...rest }] = qraft.service.operation.useQueries([ + { queryKey: ['key1'], queryFn: () => {} }, + { queryKey: ['key2'], queryFn: () => {} }, + ]) + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useSuspenseQuery is destructured with rest', + code: normalizeIndent` + function Component() { + const { data, ...rest } = qraft.service.operation.useSuspenseQuery() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useSuspenseInfiniteQuery is destructured with rest', + code: normalizeIndent` + function Component() { + const { data, ...rest } = qraft.service.operation.useSuspenseInfiniteQuery() + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useSuspenseQueries is destructured with rest', + code: normalizeIndent` + function Component() { + const [query1, { data, ...rest }] = qraft.service.operation.useSuspenseQueries([ + { queryKey: ['key1'], queryFn: () => {} }, + { queryKey: ['key2'], queryFn: () => {} }, + ]) + return + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useQuery result is spread in return statement', + code: normalizeIndent` + function Component() { + const query = qraft.service.operation.useQuery() + return { ...query, data: query.data[0] } + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + { + name: 'useQuery result is spread in object expression', + code: normalizeIndent` + function Component() { + const query = qraft.service.operation.useQuery() + const result = { ...query, data: query.data[0] } + return result + } + `, + errors: [{ messageId: 'objectRestDestructure' }], + }, + ], +}); diff --git a/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts new file mode 100644 index 000000000..b0568d1b5 --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/no-unstable-deps.test.ts @@ -0,0 +1,125 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { + reactHookNames, + rule, + useQueryHookNames, +} from '../rules/no-unstable-deps/no-unstable-deps.rule'; + +const ruleTester = new RuleTester(); + +interface TestCase { + reactHookImport: string; + reactHookInvocation: string; + reactHookAlias: string; +} +const baseTestCases = { + valid: ({ reactHookImport, reactHookInvocation, reactHookAlias }: TestCase) => + [ + { + name: `should pass when destructured mutate is passed to ${reactHookAlias} as dependency`, + code: ` + ${reactHookImport} + + function Component() { + const { mutate } = qraft.service.operation.useMutation({ mutationFn: (value: string) => value }); + const callback = ${reactHookInvocation}(() => { mutate('hello') }, [mutate]); + return; + } + `, + }, + ].concat( + useQueryHookNames.map((queryHook) => ({ + name: `should pass result of ${queryHook} is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + + function Component() { + const { refetch } = qraft.service.operation.${queryHook}({ queryFn: (value: string) => value }); + const callback = ${reactHookInvocation}(() => { query.refetch() }, [refetch]); + return; + } + `, + })) + ), + invalid: ({ + reactHookImport, + reactHookInvocation, + reactHookAlias, + }: TestCase) => + [ + { + name: `result of useMutation is passed to ${reactHookInvocation} as dependency `, + code: ` + ${reactHookImport} + + function Component() { + const mutation = qraft.service.operation.useMutation({ mutationFn: (value: string) => value }); + const callback = ${reactHookInvocation}(() => { mutation.mutate('hello') }, [mutation]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps' as const, + data: { reactHook: reactHookAlias, queryHook: 'useMutation' }, + }, + ], + }, + ].concat( + useQueryHookNames.map((queryHook) => ({ + name: `result of ${queryHook} is passed to ${reactHookInvocation} as dependency`, + code: ` + ${reactHookImport} + + function Component() { + const query = qraft.service.operation.${queryHook}({ queryFn: (value: string) => value }); + const callback = ${reactHookInvocation}(() => { query.refetch() }, [query]); + return; + } + `, + errors: [ + { + messageId: 'noUnstableDeps' as const, + data: { reactHook: reactHookAlias, queryHook }, + }, + ], + })) + ), +}; + +const testCases = (reactHookName: string) => [ + { + reactHookImport: 'import * as React from "React";', + reactHookInvocation: `React.${reactHookName}`, + reactHookAlias: reactHookName, + }, + { + reactHookImport: `import { ${reactHookName} } from "React";`, + reactHookInvocation: reactHookName, + reactHookAlias: reactHookName, + }, + { + reactHookImport: `import { ${reactHookName} as useAlias } from "React";`, + reactHookInvocation: 'useAlias', + reactHookAlias: 'useAlias', + }, +]; + +reactHookNames.forEach((reactHookName) => { + testCases(reactHookName).forEach( + ({ reactHookInvocation, reactHookAlias, reactHookImport }) => { + ruleTester.run('no-unstable-deps', rule, { + valid: baseTestCases.valid({ + reactHookImport, + reactHookInvocation, + reactHookAlias, + }), + invalid: baseTestCases.invalid({ + reactHookImport, + reactHookInvocation, + reactHookAlias, + }), + }); + } + ); +}); diff --git a/packages/eslint-plugin-query/src/__tests__/test-utils.test.ts b/packages/eslint-plugin-query/src/__tests__/test-utils.test.ts new file mode 100644 index 000000000..ceca04007 --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/test-utils.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'vitest'; +import { + expectArrayEqualIgnoreOrder, + generateInterleavedCombinations, + generatePartialCombinations, + generatePermutations, +} from './test-utils'; + +describe('test-utils', () => { + describe('generatePermutations', () => { + const testCases = [ + { + input: ['a', 'b', 'c'], + expected: [ + ['a', 'b', 'c'], + ['a', 'c', 'b'], + ['b', 'a', 'c'], + ['b', 'c', 'a'], + ['c', 'a', 'b'], + ['c', 'b', 'a'], + ], + }, + { + input: ['a', 'b'], + expected: [ + ['a', 'b'], + ['b', 'a'], + ], + }, + { + input: ['a'], + expected: [['a']], + }, + ]; + test.each(testCases)('$input $expected', ({ input, expected }) => { + const permutations = generatePermutations(input); + expect(permutations).toEqual(expected); + }); + }); + + describe('generatePartialCombinations', () => { + const testCases = [ + { + input: ['a', 'b', 'c'], + minLength: 2, + expected: [ + ['a', 'b'], + ['a', 'c'], + ['b', 'c'], + ['a', 'b', 'c'], + ], + }, + { + input: ['a', 'b'], + expected: [['a', 'b']], + minLength: 2, + }, + { + input: ['a'], + expected: [], + minLength: 2, + }, + { + input: ['a'], + expected: [['a']], + minLength: 1, + }, + { + input: ['a'], + expected: [[], ['a']], + minLength: 0, + }, + ]; + test.each(testCases)( + '$input $minLength $expected', + ({ input, minLength, expected }) => { + const combinations = generatePartialCombinations(input, minLength); + expectArrayEqualIgnoreOrder(combinations, expected); + } + ); + }); + + describe('generateInterleavedCombinations', () => { + const testCases = [ + { + data: ['a', 'b'], + additional: ['x'], + expected: [ + ['a', 'b'], + ['x', 'a', 'b'], + ['a', 'x', 'b'], + ['a', 'b', 'x'], + ], + }, + ]; + test.each(testCases)( + '$input $expected', + ({ data, additional, expected }) => { + const combinations = generateInterleavedCombinations(data, additional); + expectArrayEqualIgnoreOrder(combinations, expected); + } + ); + }); +}); diff --git a/packages/eslint-plugin-query/src/__tests__/test-utils.ts b/packages/eslint-plugin-query/src/__tests__/test-utils.ts new file mode 100644 index 000000000..b203902eb --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/test-utils.ts @@ -0,0 +1,108 @@ +import { expect } from 'vitest'; + +export function normalizeIndent(template: TemplateStringsArray) { + const codeLines = template[0]?.split('\n') ?? ['']; + const leftPadding = codeLines[1]?.match(/\s+/)?.[0] ?? ''; + return codeLines.map((line) => line.slice(leftPadding.length)).join('\n'); +} + +export function generatePermutations(arr: Array): Array> { + if (arr.length <= 1) { + return [arr]; + } + + const result: Array> = []; + for (let i = 0; i < arr.length; i++) { + const rest = arr.slice(0, i).concat(arr.slice(i + 1)); + const restPermutations = generatePermutations(rest); + for (const perm of restPermutations) { + result.push([arr[i]!, ...perm]); + } + } + + return result; +} + +export function generatePartialCombinations( + arr: ReadonlyArray, + minLength: number +): Array> { + const result: Array> = []; + + function backtrack(start: number, current: Array) { + if (current.length > minLength - 1) { + result.push([...current]); + } + for (let i = start; i < arr.length; i++) { + current.push(arr[i]!); + backtrack(i + 1, current); + current.pop(); + } + } + backtrack(0, []); + return result; +} + +export function expectArrayEqualIgnoreOrder(a: Array, b: Array) { + expect([...a].sort()).toEqual([...b].sort()); +} + +export function generateInterleavedCombinations< + TData, + TAdditional, + TResult extends TData | TAdditional, +>( + data: Array | ReadonlyArray, + additional: Array | ReadonlyArray +): Array> { + const result: Array> = []; + + function getSubsets(array: Array): Array> { + return array.reduce( + (subsets, value) => { + return subsets.concat(subsets.map((set) => [...set, value])); + }, + [[]] as Array> + ); + } + + function insertAtPositions( + baseData: Array, + subset: Array + ): Array> { + const combinations: Array> = []; + + const recurse = ( + currentData: Array, + currentSubset: Array, + start: number + ): void => { + if (currentSubset.length === 0) { + combinations.push([...currentData]); + return; + } + + for (let i = start; i <= currentData.length; i++) { + const newData = [ + ...currentData.slice(0, i), + currentSubset[0]!, + ...currentData.slice(i), + ]; + recurse(newData, currentSubset.slice(1), i + 1); + } + }; + + recurse(baseData, subset, 0); + return combinations; + } + + const subsets = getSubsets(additional as Array); + + subsets.forEach((subset) => { + result.push( + ...insertAtPositions(data as Array, subset as Array) + ); + }); + + return result; +} diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts new file mode 100644 index 000000000..8d7b1e7d5 --- /dev/null +++ b/packages/eslint-plugin-query/src/index.ts @@ -0,0 +1,54 @@ +import type { RuleModule } from '@typescript-eslint/utils/ts-eslint'; +import type { ESLint, Linter } from 'eslint'; +import { rules } from './rules'; + +type RuleKey = keyof typeof rules; + +export interface Plugin extends Omit { + rules: Record>; + configs: { + recommended: ESLint.ConfigData; + 'flat/recommended': Array; + }; +} + +export const plugin: Plugin = { + meta: { + name: '@openapi-qraft/eslint-plugin-query', + }, + configs: {} as Plugin['configs'], + rules, +}; + +// Assign configs here so we can reference `plugin` +Object.assign(plugin.configs, { + recommended: { + plugins: ['@openapi-qraft/query'], + rules: { + '@openapi-qraft/query/no-rest-destructuring': 'warn', + '@openapi-qraft/query/no-unstable-deps': 'error', + }, + }, + 'flat/recommended': [ + { + name: 'openapi-qraft/query/flat/recommended', + plugins: { + '@openapi-qraft/query': plugin, + }, + rules: { + '@openapi-qraft/query/no-rest-destructuring': 'warn', + '@openapi-qraft/query/no-unstable-deps': 'error', + }, + }, + ], +}); + +// Reexport rules & configs for the Legacy config +export { rules }; +export const configs = plugin.configs; + +/** + * @alias + * @type {Plugin} + **/ +export default plugin; diff --git a/packages/eslint-plugin-query/src/rules.ts b/packages/eslint-plugin-query/src/rules.ts new file mode 100644 index 000000000..5df945bc1 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules.ts @@ -0,0 +1,17 @@ +import type { ESLintUtils } from '@typescript-eslint/utils'; +import type { ExtraRuleDocs } from './types'; +import * as noRestDestructuring from './rules/no-rest-destructuring/no-rest-destructuring.rule'; +import * as noUnstableDeps from './rules/no-unstable-deps/no-unstable-deps.rule'; + +export const rules: Record< + string, + ESLintUtils.RuleModule< + string, + ReadonlyArray, + ExtraRuleDocs, + ESLintUtils.RuleListener + > +> = { + [noRestDestructuring.name]: noRestDestructuring.rule, + [noUnstableDeps.name]: noUnstableDeps.rule, +}; diff --git a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts new file mode 100644 index 000000000..f9a9cb796 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts @@ -0,0 +1,135 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import type { ExtraRuleDocs } from '../../types'; +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; +import { createClientNameRegex } from '../../utils/create-client-name-regex'; +import { getDocsUrl } from '../../utils/get-docs-url'; +import { getRootIdentifier } from '../../utils/get-root-identifier'; +import { NoRestDestructuringUtils } from './no-rest-destructuring.utils'; + +export const name = 'no-rest-destructuring'; + +const createRule = ESLintUtils.RuleCreator(getDocsUrl); + +const queryHooks = [ + 'useQuery', + 'useQueries', + 'useInfiniteQuery', + 'useSuspenseQuery', + 'useSuspenseQueries', + 'useSuspenseInfiniteQuery', +]; + +export const rule = createRule({ + name, + meta: { + type: 'problem', + docs: { + description: 'Disallows rest destructuring in queries', + recommended: 'warn', + }, + messages: { + objectRestDestructure: `Object rest destructuring on a query will observe all changes to the query, leading to excessive re-renders.`, + }, + schema: [ + { + type: 'object', + properties: { + clientNamePattern: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [{}], + + create: (context) => { + const option = + (context.options[0] as { clientNamePattern?: string } | undefined) ?? {}; + + const clientRegex = createClientNameRegex(option.clientNamePattern); + + const queryResultVariables = new Set(); + + return { + CallExpression: (node) => { + if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) return; + + const callee = node.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression) return; + const hookName = getHookNameIfTargetFromClient(clientRegex, callee); + if (!hookName) return; + const returnValue = node.parent.id; + + if (hookName !== 'useQueries' && hookName !== 'useSuspenseQueries') { + if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) { + return context.report({ + node: node.parent, + messageId: 'objectRestDestructure', + }); + } + + if (returnValue.type === AST_NODE_TYPES.Identifier) { + queryResultVariables.add(returnValue.name); + } + + return; + } + + if (returnValue.type !== AST_NODE_TYPES.ArrayPattern) { + if (returnValue.type === AST_NODE_TYPES.Identifier) { + queryResultVariables.add(returnValue.name); + } + return; + } + + returnValue.elements.forEach((queryResult) => { + if (queryResult === null) { + return; + } + if (NoRestDestructuringUtils.isObjectRestDestructuring(queryResult)) { + context.report({ + node: queryResult, + messageId: 'objectRestDestructure', + }); + } + }); + }, + + VariableDeclarator: (node) => { + if ( + node.init?.type === AST_NODE_TYPES.Identifier && + queryResultVariables.has(node.init.name) && + NoRestDestructuringUtils.isObjectRestDestructuring(node.id) + ) { + context.report({ + node, + messageId: 'objectRestDestructure', + }); + } + }, + + SpreadElement: (node) => { + if ( + node.argument.type === AST_NODE_TYPES.Identifier && + queryResultVariables.has(node.argument.name) + ) { + context.report({ + node, + messageId: 'objectRestDestructure', + }); + } + }, + }; + }, +}); + +function getHookNameIfTargetFromClient( + clientRegex: RegExp, + callee: TSESTree.MemberExpression +): string | undefined { + if (callee.property.type !== AST_NODE_TYPES.Identifier) return; + const name = callee.property.name; + if (!queryHooks.includes(name)) return; + const root = getRootIdentifier(callee.object); + return root && clientRegex.test(root.name) ? name : undefined; +} diff --git a/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts new file mode 100644 index 000000000..fb5fcdf91 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts @@ -0,0 +1,11 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +export const NoRestDestructuringUtils = { + isObjectRestDestructuring(node: TSESTree.Node): boolean { + if (node.type !== AST_NODE_TYPES.ObjectPattern) { + return false; + } + return node.properties.some((p) => p.type === AST_NODE_TYPES.RestElement); + }, +}; diff --git a/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts b/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts new file mode 100644 index 000000000..9f1eeb72b --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/no-unstable-deps/no-unstable-deps.rule.ts @@ -0,0 +1,137 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import type { ExtraRuleDocs } from '../../types'; +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; +import { createClientNameRegex } from '../../utils/create-client-name-regex'; +import { getDocsUrl } from '../../utils/get-docs-url'; +import { getRootIdentifier } from '../../utils/get-root-identifier'; + +export const name = 'no-unstable-deps'; + +export const reactHookNames = ['useEffect', 'useCallback', 'useMemo']; +export const useQueryHookNames = [ + 'useQuery', + 'useSuspenseQuery', + 'useQueries', + 'useSuspenseQueries', + 'useInfiniteQuery', + 'useSuspenseInfiniteQuery', +]; +const allHookNames = ['useMutation', ...useQueryHookNames]; +const createRule = ESLintUtils.RuleCreator(getDocsUrl); + +export const rule = createRule({ + name, + meta: { + type: 'problem', + docs: { + description: + 'Disallow putting the result of query hooks directly in a React hook dependency array', + recommended: 'error', + }, + messages: { + noUnstableDeps: `The result of {{queryHook}} is not referentially stable, so don't pass it directly into the dependencies array of {{reactHook}}. Instead, destructure the return value of {{queryHook}} and pass the destructured values into the dependency array of {{reactHook}}.`, + }, + schema: [ + { + type: 'object', + properties: { + clientNamePattern: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [{}], + + create: (context) => { + const option = + (context.options[0] as { clientNamePattern?: string } | undefined) ?? {}; + const clientRegex = createClientNameRegex(option.clientNamePattern); + + const trackedVariables: Record = {}; + const hookAliasMap: Record = {}; + + function getReactHook(node: TSESTree.CallExpression): string | undefined { + if (node.callee.type === 'Identifier') { + const calleeName = node.callee.name; + if (reactHookNames.includes(calleeName) || calleeName in hookAliasMap) { + // Return the actually used identifier (could be alias) + return calleeName; + } + } else if ( + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'React' && + node.callee.property.type === 'Identifier' && + reactHookNames.includes(node.callee.property.name) + ) { + return node.callee.property.name; + } + return undefined; + } + + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if ( + node.specifiers.length > 0 && + node.importKind === 'value' && + node.source.value === 'React' + ) { + node.specifiers.forEach((specifier) => { + if ( + specifier.type === AST_NODE_TYPES.ImportSpecifier && + specifier.imported.type === AST_NODE_TYPES.Identifier && + reactHookNames.includes(specifier.imported.name) + ) { + hookAliasMap[specifier.local.name] = specifier.imported.name; + } + }); + } + }, + + VariableDeclarator(node) { + if (node.init?.type !== AST_NODE_TYPES.CallExpression) return; + const hook = isQueryHookFromClient(clientRegex, node.init); + if (!hook) return; + if (node.id.type === AST_NODE_TYPES.Identifier) { + trackedVariables[node.id.name] = hook; + } + }, + CallExpression: (node) => { + const reactHook = getReactHook(node); + if (!reactHook) return; + if (node.arguments.length <= 1) return; + if (node.arguments[1]?.type !== AST_NODE_TYPES.ArrayExpression) return; + + const depsArray = node.arguments[1].elements; + depsArray.forEach((dep) => { + if (dep?.type !== AST_NODE_TYPES.Identifier) return; + const queryHook = trackedVariables[dep.name]; + if (!queryHook) return; + context.report({ + node: dep, + messageId: 'noUnstableDeps', + data: { + queryHook, + reactHook, + }, + }); + }); + }, + }; + }, +}); + +function isQueryHookFromClient( + clientRegex: RegExp, + call: TSESTree.CallExpression +): string | null { + if (call.callee.type !== AST_NODE_TYPES.MemberExpression) return null; + const member = call.callee; + if (member.property.type !== AST_NODE_TYPES.Identifier) return null; + const hook = member.property.name; + if (!allHookNames.includes(hook)) return null; + const root = getRootIdentifier(member.object); + if (!root) return null; + return clientRegex.test(root.name) ? hook : null; +} diff --git a/packages/eslint-plugin-query/src/types.ts b/packages/eslint-plugin-query/src/types.ts new file mode 100644 index 000000000..c752e59bd --- /dev/null +++ b/packages/eslint-plugin-query/src/types.ts @@ -0,0 +1,3 @@ +export type ExtraRuleDocs = { + recommended: 'strict' | 'error' | 'warn'; +}; diff --git a/packages/eslint-plugin-query/src/utils/create-client-name-regex.ts b/packages/eslint-plugin-query/src/utils/create-client-name-regex.ts new file mode 100644 index 000000000..d0ada6135 --- /dev/null +++ b/packages/eslint-plugin-query/src/utils/create-client-name-regex.ts @@ -0,0 +1,32 @@ +export function createClientNameRegex(clientNamePattern: string | undefined) { + clientNamePattern = clientNamePattern?.trim(); + + if (clientNamePattern) { + // Check if pattern is in regex format: /pattern/flags + const regexMatch = clientNamePattern.match(/^\/(.*)\/([gimuy]*)$/); + + if (regexMatch) { + const regexPattern = regexMatch[1].trim(); + const flags = regexMatch[2]; + + // Validate that regex pattern is not empty or whitespace-only + if (regexPattern === '') { + throw new Error( + 'clientNamePattern cannot be empty or contain only whitespace' + ); + } + + return new RegExp(regexPattern, flags); + } + + // Fallback to treating as plain string pattern + return new RegExp(clientNamePattern); + } else if (clientNamePattern === '') { + throw new Error( + 'clientNamePattern cannot be empty or contain only whitespace' + ); + } + + // Default if no pattern provided + return /qraft|api/i; +} diff --git a/packages/eslint-plugin-query/src/utils/get-docs-url.ts b/packages/eslint-plugin-query/src/utils/get-docs-url.ts new file mode 100644 index 000000000..00ff6280c --- /dev/null +++ b/packages/eslint-plugin-query/src/utils/get-docs-url.ts @@ -0,0 +1,2 @@ +export const getDocsUrl = (ruleName: string): string => + `https://openapi-qraft.github.io/openapi-qraft/docs/eslint/${ruleName}`; diff --git a/packages/eslint-plugin-query/src/utils/get-root-identifier.ts b/packages/eslint-plugin-query/src/utils/get-root-identifier.ts new file mode 100644 index 000000000..1451d25b1 --- /dev/null +++ b/packages/eslint-plugin-query/src/utils/get-root-identifier.ts @@ -0,0 +1,25 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +export function getRootIdentifier( + node: TSESTree.Node +): TSESTree.Identifier | undefined { + let current: TSESTree.Node | undefined = node; + while ( + current.type === AST_NODE_TYPES.MemberExpression || + current.type === AST_NODE_TYPES.TSNonNullExpression || + current.type === AST_NODE_TYPES.ChainExpression + ) { + if (current.type === AST_NODE_TYPES.MemberExpression) { + current = current.object; + continue; + } + if (current.type === AST_NODE_TYPES.TSNonNullExpression) { + current = current.expression; + continue; + } + + current = current.expression; + } + return current.type === AST_NODE_TYPES.Identifier ? current : undefined; +} diff --git a/packages/eslint-plugin-query/tsconfig.build.json b/packages/eslint-plugin-query/tsconfig.build.json new file mode 100644 index 000000000..20e1d9241 --- /dev/null +++ b/packages/eslint-plugin-query/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declarationDir": "dist/types", + "declaration": true, + "declarationMap": true + }, + "exclude": [ + "src/**/*.spec.ts", + "src/__tests__/**", + "src/**/*.test.ts", + "src/tmp/**" + ] +} diff --git a/packages/eslint-plugin-query/tsconfig.json b/packages/eslint-plugin-query/tsconfig.json new file mode 100644 index 000000000..25c315be2 --- /dev/null +++ b/packages/eslint-plugin-query/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "baseUrl": ".", + "declaration": true, + "resolveJsonModule": true, + "module": "ES2022", + "moduleResolution": "Bundler" + }, + "include": ["src/**/*"] +} diff --git a/packages/eslint-plugin-query/vitest.config.ts b/packages/eslint-plugin-query/vitest.config.ts new file mode 100644 index 000000000..fd6aac9f2 --- /dev/null +++ b/packages/eslint-plugin-query/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + css: false, + coverage: { + include: ['src/**/*'], + exclude: [ + 'src/__tests__/**', + 'src/index.ts', + 'src/rules.ts', + 'src/**/*.d.ts', + ], + }, + }, +}); diff --git a/packages/react-client/eslint.config.js b/packages/react-client/eslint.config.js index a292706cc..896851f71 100644 --- a/packages/react-client/eslint.config.js +++ b/packages/react-client/eslint.config.js @@ -1,10 +1,12 @@ import openAPIQraftConfig from '@openapi-qraft/eslint-config/eslint.vanilla.config'; +import pluginQraftQuery from '@openapi-qraft/eslint-plugin-query'; import reactCompiler from 'eslint-plugin-react-compiler'; import globals from 'globals'; export default [ { ignores: ['src/tests/fixtures/api/**/*'] }, { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, + ...pluginQraftQuery.configs['flat/recommended'], ...openAPIQraftConfig, { ignores: [ diff --git a/packages/react-client/package.json b/packages/react-client/package.json index d28ba91b4..cfaf7763c 100644 --- a/packages/react-client/package.json +++ b/packages/react-client/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@openapi-qraft/cli": "workspace:*", "@openapi-qraft/eslint-config": "workspace:*", + "@openapi-qraft/eslint-plugin-query": "workspace:*", "@openapi-qraft/openapi-typescript-plugin": "workspace:^", "@openapi-qraft/rollup-config": "workspace:*", "@openapi-qraft/tanstack-query-react-plugin": "workspace:*", diff --git a/website/docs/eslint/_category_.json b/website/docs/eslint/_category_.json new file mode 100644 index 000000000..77f14276e --- /dev/null +++ b/website/docs/eslint/_category_.json @@ -0,0 +1,7 @@ +{ + "label": "ESLint Plugin", + "position": 120, + "collapsible": true, + "collapsed": true, + "link": null +} diff --git a/website/docs/eslint/eslint-plugin-query.md b/website/docs/eslint/eslint-plugin-query.md new file mode 100644 index 000000000..7011d0952 --- /dev/null +++ b/website/docs/eslint/eslint-plugin-query.md @@ -0,0 +1,142 @@ +--- +sidebar_position: 1 +id: eslint-plugin-query +title: ESLint Plugin Query +--- + +This ESLint plugin is tailored for projects using `@openapi-qraft/react`. It helps enforce best practices around the generated hooks (e.g. +`qraft.service.operation.useQuery()`, `qraft.useInfiniteQuery()`), and prevents common mistakes that lead to unnecessary re-renders or type inference issues. + +## Installation + +The plugin is a separate package that you need to install: + +```bash npm2yarn +npm i -D @openapi-qraft/eslint-plugin-query +``` + +## Flat Config (`eslint.config.js`) + +### Recommended setup + +To enable all the recommended rules for our plugin, add the following config: + +```js +import pluginQraftQuery from '@openapi-qraft/eslint-plugin-query' + +export default [ + ...pluginQraftQuery.configs['flat/recommended'], + // Any other config... +] +``` + +## Legacy Config (`.eslintrc`) + +### Recommended setup + +To enable all the recommended rules for our plugin, add `plugin:@openapi-qraft/query/recommended` in extends: + +```json +{ + "extends": [ + "plugin:@openapi-qraft/query/recommended" + ] +} +``` + +## Custom setup + +Alternatively, configure only the rules you want to use. + +### Flat config (`eslint.config.js`) + +```js +import pluginQraftQuery from '@openapi-qraft/eslint-plugin-query'; + +export default [ + { + plugins: { + '@openapi-qraft/query': pluginQraftQuery, + }, + rules: { + // enable only the rules you need + '@openapi-qraft/query/no-rest-destructuring': 'warn', + '@openapi-qraft/query/no-unstable-deps': 'error', + }, + }, + // Any other config... +]; +``` + +### Legacy config (`.eslintrc`) + +```json +{ + "plugins": [ + "@openapi-qraft/query" + ], + "rules": { + "@openapi-qraft/query/no-rest-destructuring": "warn", + "@openapi-qraft/query/no-unstable-deps": "error" + } +} +``` + +## Client name detection + +Some rules in this plugin detect Qraft-generated hooks only when they are called as methods on your client instance (e.g. `qraft.service.operation.useQuery()`). +To support custom client names, each rule accepts an optional `clientNamePattern` (string or regex literal) option. You can pass a regex literal like +`/customClientName/i`. By default, it matches the case-insensitive regex `/qraft|api/i`. + +### Optional: clientNamePattern + +### Flat config (`eslint.config.js`) + +```js +import pluginQraftQuery from '@openapi-qraft/eslint-plugin-query' + +export default [ + { + plugins: { '@openapi-qraft/query': pluginQraftQuery }, + rules: { + '@openapi-qraft/query/no-rest-destructuring': [ + 'warn', + { clientNamePattern: '/myQraftClient/i' }, + ], + '@openapi-qraft/query/no-unstable-deps': [ + 'error', + { clientNamePattern: '/myQraftClient/i' }, + ], + }, + }, +] +``` + +### Legacy config (`.eslintrc`) + +```json +{ + "plugins": [ + "@openapi-qraft/query" + ], + "rules": { + "@openapi-qraft/query/no-rest-destructuring": [ + "warn", + { + "clientNamePattern": "/myQraftClient/i" + } + ], + "@openapi-qraft/query/no-unstable-deps": [ + "error", + { + "clientNamePattern": "/myQraftClient/i" + } + ] + } +} +``` + +## Rules + +- [@openapi-qraft/query/no-rest-destructuring](./no-rest-destructuring.md) +- [@openapi-qraft/query/no-unstable-deps](./no-unstable-deps.md) diff --git a/website/docs/eslint/no-rest-destructuring.md b/website/docs/eslint/no-rest-destructuring.md new file mode 100644 index 000000000..e27da8ec2 --- /dev/null +++ b/website/docs/eslint/no-rest-destructuring.md @@ -0,0 +1,72 @@ +--- +sidebar_position: 2 +id: no-rest-destructuring +title: No Rest Destructuring +--- + +:::info +Disallow object rest destructuring on query results +::: + +**Rule ID**: `@openapi-qraft/query/no-rest-destructuring` + +Using object rest destructuring on Qraft query results automatically subscribes to every field of the result, which may +cause unnecessary re-renders. +This rule ensures you only subscribe to the fields you actually need. + +## Rule Details + +Examples of **incorrect** code for this rule (Qraft hooks): + +```tsx +/* eslint "@openapi-qraft/query/no-rest-destructuring": "warn" */ + +const useTodos = () => { + const { data: todos, ...rest } = qraft.service.operation.useQuery() + return { todos, ...rest } +} +``` + +Examples of **correct** code for this rule: + +```tsx +const todosQuery = qraft.useQuery() + +// normal object destructuring is fine +const { data: todos } = todosQuery +``` + +## When Not To Use It + +If you set the `notifyOnChangeProps` options manually, you can disable this rule. +Since you are not using tracked queries, you are responsible for specifying which props should trigger a re-render. + +## Options + +- clientNamePattern (string or regex literal, optional): you can provide a string or a regex literal like + `/customClientName/i` to match your Qraft client root identifier. Defaults to the case-insensitive regex + `/qraft|api/i`. + +Example: + +```js +// eslint.config.js (flat) +import pluginQraftQuery from '@openapi-qraft/eslint-plugin-query'; + +export default [ + { + plugins: { '@openapi-qraft/query': pluginQraftQuery }, + rules: { + '@openapi-qraft/query/no-rest-destructuring': [ + 'warn', + { clientNamePattern: '/myQraftClient/i' }, + ], + }, + }, +]; +``` + +## Attributes + +- [x] ✅ Recommended +- [ ] 🔧 Fixable diff --git a/website/docs/eslint/no-unstable-deps.md b/website/docs/eslint/no-unstable-deps.md new file mode 100644 index 000000000..f114cf72d --- /dev/null +++ b/website/docs/eslint/no-unstable-deps.md @@ -0,0 +1,65 @@ +--- +id: no-unstable-deps +title: No Unstable Deps +--- + +:::info +Disallow putting the result of query hooks directly in a React hook dependency array +::: + +**Rule ID**: `@openapi-qraft/query/no-unstable-deps` + +The object returned from the following Qraft hooks is **not** referentially stable: + +- `qraft.service.operation.useQuery` +- `qraft.service.operation.useSuspenseQuery` +- `qraft.service.operation.useQueries` +- `qraft.service.operation.useSuspenseQueries` +- `qraft.service.operation.useInfiniteQuery` +- `qraft.service.operation.useSuspenseInfiniteQuery` +- `qraft.service.operation.useMutation` + +The object returned from those hooks should **not** be put directly into the dependency array of a React hook (e.g. `useEffect`, `useMemo`, `useCallback`). +Instead, destructure the return value and pass the destructured values into the dependency array. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```tsx +/* eslint "@openapi-qraft/query/no-unstable-deps": "warn" */ +import { useCallback } from 'React' + +function Component() { + const mutation = qraft.service.operation.useMutation() + const callback = useCallback(() => { + mutation.mutate({ body: 'hello' }) + }, [mutation]) + return null +} +``` + +Examples of **correct** code for this rule: + +```tsx +/* eslint "@openapi-qraft/query/no-unstable-deps": "warn" */ +import { useCallback } from 'React' + +function Component() { + const { mutate } = qraft.service.operation.useMutation() + const callback = useCallback(() => { + mutate('hello') + }, [mutate]) + return null +} +``` + +## Attributes + +- [x] ✅ Recommended +- [ ] 🔧 Fixable + +## Options + +- clientNamePattern (string or regex literal, optional): you can provide a string or a regex literal like `/customClientName/i` to match your Qraft client root + identifier. Defaults to the case-insensitive regex `/qraft|api/i`. diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 1a37d553a..a6bed4cc7 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -126,14 +126,6 @@ const config = { apiKey: '68a397ed6b627ba01e722c54228bd79f', indexName: 'openapi-qraftio', }, - announcementBar: { - id: `announcementBar-v2`, // used in `localStorage` - content: `️⛏︎ - - OpenAPI Qraft 2 - is out! 🎉 - `, - }, }), }; diff --git a/yarn.lock b/yarn.lock index a07059793..5f700045a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5406,6 +5406,17 @@ __metadata: languageName: node linkType: hard +"@eslint-community/eslint-utils@npm:^4.7.0": + version: 4.7.0 + resolution: "@eslint-community/eslint-utils@npm:4.7.0" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/c0f4f2bd73b7b7a9de74b716a664873d08ab71ab439e51befe77d61915af41a81ecec93b408778b3a7856185244c34c2c8ee28912072ec14def84ba2dec70adf + languageName: node + linkType: hard + "@eslint-community/regexpp@npm:^4.10.0": version: 4.10.0 resolution: "@eslint-community/regexpp@npm:4.10.0" @@ -6049,6 +6060,27 @@ __metadata: languageName: unknown linkType: soft +"@openapi-qraft/eslint-plugin-query@workspace:*, @openapi-qraft/eslint-plugin-query@workspace:packages/eslint-plugin-query": + version: 0.0.0-use.local + resolution: "@openapi-qraft/eslint-plugin-query@workspace:packages/eslint-plugin-query" + dependencies: + "@openapi-qraft/eslint-config": "workspace:*" + "@openapi-qraft/rollup-config": "workspace:*" + "@types/node": "npm:^20.16.5" + "@typescript-eslint/rule-tester": "npm:^8.37.0" + "@typescript-eslint/typescript-estree": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.37.0" + "@vitest/coverage-v8": "npm:^3.0.5" + eslint: "npm:^9.24.0" + rimraf: "npm:^5.0.10" + rollup: "npm:~4.22.4" + typescript: "npm:^5.6.2" + vitest: "npm:^3.0.5" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + languageName: unknown + linkType: soft + "@openapi-qraft/openapi-typescript-plugin@workspace:*, @openapi-qraft/openapi-typescript-plugin@workspace:^, @openapi-qraft/openapi-typescript-plugin@workspace:packages/openapi-typescript-plugin": version: 0.0.0-use.local resolution: "@openapi-qraft/openapi-typescript-plugin@workspace:packages/openapi-typescript-plugin" @@ -6099,6 +6131,7 @@ __metadata: dependencies: "@openapi-qraft/cli": "workspace:*" "@openapi-qraft/eslint-config": "workspace:*" + "@openapi-qraft/eslint-plugin-query": "workspace:*" "@openapi-qraft/openapi-typescript-plugin": "workspace:^" "@openapi-qraft/rollup-config": "workspace:*" "@openapi-qraft/tanstack-query-react-plugin": "workspace:*" @@ -7961,6 +7994,52 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/parser@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/parser@npm:8.39.1" + dependencies: + "@typescript-eslint/scope-manager": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" + debug: "npm:^4.3.4" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/da30372c4e8dee48a0c421996bf0bf73a62a57039ee6b817eda64de2d70fdb88dd20b50615c81be7e68fd29cdd7852829b859bb8539b4a4c78030f93acaf5664 + languageName: node + linkType: hard + +"@typescript-eslint/project-service@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/project-service@npm:8.39.1" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/40207af4f4e2a260ea276766d502c4736f6dc5488e84bbab6444e2786289ece2dbca2686323c48d4e9c265e409a309bf3d97d4aa03767dff8cc7642b436bda35 + languageName: node + linkType: hard + +"@typescript-eslint/rule-tester@npm:^8.37.0": + version: 8.39.1 + resolution: "@typescript-eslint/rule-tester@npm:8.39.1" + dependencies: + "@typescript-eslint/parser": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + "@typescript-eslint/utils": "npm:8.39.1" + ajv: "npm:^6.12.6" + json-stable-stringify-without-jsonify: "npm:^1.0.1" + lodash.merge: "npm:4.6.2" + semver: "npm:^7.6.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + checksum: 10c0/f008e6d859db406d59b363cec5fc0c645867df5eb269be800e15daf5b80f8d7d17c80cdb3f305e8a0edc539b266b137e6de2aa6c5b20507cb7a65fe969b5f7bb + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:8.24.0": version: 8.24.0 resolution: "@typescript-eslint/scope-manager@npm:8.24.0" @@ -7981,6 +8060,25 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/scope-manager@npm:8.39.1" + dependencies: + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" + checksum: 10c0/9466db557c1a0eaaf24b0ece5810413d11390d046bf6e47c4074879e8dba0348b835a21106c842ab20ff85f2384312cf9e20bfe7684e31640696e29957003511 + languageName: node + linkType: hard + +"@typescript-eslint/tsconfig-utils@npm:8.39.1, @typescript-eslint/tsconfig-utils@npm:^8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.39.1" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/664dff0b4ae908cb98c78f9ca73c36cf57c3a2206965d9d0659649ffc02347eb30e1452499671a425592f14a2a5c5eb82ae389b34f3c415a12119506b4ebb61c + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.24.0": version: 8.24.0 resolution: "@typescript-eslint/type-utils@npm:8.24.0" @@ -8010,6 +8108,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.39.1, @typescript-eslint/types@npm:^8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/types@npm:8.39.1" + checksum: 10c0/0e188d2d52509a24c500a87adf561387ffcac56b62cb9fd0ca1f929bb3d4eedb6b8f9d516c1890855d39930c9dd8d502d5b4600b8c9cc832d3ebb595d81c7533 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.24.0": version: 8.24.0 resolution: "@typescript-eslint/typescript-estree@npm:8.24.0" @@ -8046,6 +8151,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:8.39.1, @typescript-eslint/typescript-estree@npm:^8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.39.1" + dependencies: + "@typescript-eslint/project-service": "npm:8.39.1" + "@typescript-eslint/tsconfig-utils": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/1de1a37fed354600a08bc971492c2f14238f0a4bf07a43bedb416c17b7312d18bec92c68c8f2790bb0a1bffcd757f7962914be9f6213068f18f6c4fdde259af4 + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.24.0": version: 8.24.0 resolution: "@typescript-eslint/utils@npm:8.24.0" @@ -8061,6 +8186,21 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:8.39.1, @typescript-eslint/utils@npm:^8.37.0": + version: 8.39.1 + resolution: "@typescript-eslint/utils@npm:8.39.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/ebc01d736af43728df9a0915058d0c771dec9cc58846ffdcbb986c78e7dabf547ea7daecd75db58b2af88a3c2a43de8a7e5f81feefacfa31be173fc384d25d77 + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:^8.28.0": version: 8.29.0 resolution: "@typescript-eslint/utils@npm:8.29.0" @@ -8096,6 +8236,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.39.1" + dependencies: + "@typescript-eslint/types": "npm:8.39.1" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10c0/4d81f6826a211bc2752e25cd16d1f415f28ebc92b35142402ec23f3765f2d00963b75ac06266ad9c674ca5b057d07d8c114116e5bf14f5465dde1d1aa60bc72f + languageName: node + linkType: hard + "@ungap/structured-clone@npm:^1.0.0": version: 1.2.0 resolution: "@ungap/structured-clone@npm:1.2.0" @@ -9061,7 +9211,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.12.2, ajv@npm:^6.12.4, ajv@npm:^6.12.5": +"ajv@npm:^6.12.2, ajv@npm:^6.12.4, ajv@npm:^6.12.5, ajv@npm:^6.12.6": version: 6.12.6 resolution: "ajv@npm:6.12.6" dependencies: @@ -12406,7 +12556,7 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^3.3.0": +"eslint-visitor-keys@npm:^3.3.0, eslint-visitor-keys@npm:^3.4.3": version: 3.4.3 resolution: "eslint-visitor-keys@npm:3.4.3" checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 @@ -12427,6 +12577,13 @@ __metadata: languageName: node linkType: hard +"eslint-visitor-keys@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-visitor-keys@npm:4.2.1" + checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 + languageName: node + linkType: hard + "eslint@npm:^9.24.0": version: 9.24.0 resolution: "eslint@npm:9.24.0" @@ -16151,7 +16308,7 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:^4.6.2": +"lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 @@ -23889,6 +24046,15 @@ __metadata: languageName: node linkType: hard +"ts-api-utils@npm:^2.1.0": + version: 2.1.0 + resolution: "ts-api-utils@npm:2.1.0" + peerDependencies: + typescript: ">=4.8.4" + checksum: 10c0/9806a38adea2db0f6aa217ccc6bc9c391ddba338a9fe3080676d0d50ed806d305bb90e8cef0276e793d28c8a929f400abb184ddd7ff83a416959c0f4d2ce754f + languageName: node + linkType: hard + "ts-factory-code-generator-generator@npm:^0.7.0": version: 0.7.0 resolution: "ts-factory-code-generator-generator@npm:0.7.0"