@openapi-qraft/eslint-plugin-query 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +131 -0
  3. package/dist/cjs/index.cjs +44 -0
  4. package/dist/cjs/index.cjs.map +1 -0
  5. package/dist/cjs/rules/no-rest-destructuring/no-rest-destructuring.rule.cjs +116 -0
  6. package/dist/cjs/rules/no-rest-destructuring/no-rest-destructuring.rule.cjs.map +1 -0
  7. package/dist/cjs/rules/no-rest-destructuring/no-rest-destructuring.utils.cjs +15 -0
  8. package/dist/cjs/rules/no-rest-destructuring/no-rest-destructuring.utils.cjs.map +1 -0
  9. package/dist/cjs/rules/no-unstable-deps/no-unstable-deps.rule.cjs +126 -0
  10. package/dist/cjs/rules/no-unstable-deps/no-unstable-deps.rule.cjs.map +1 -0
  11. package/dist/cjs/rules.cjs +12 -0
  12. package/dist/cjs/rules.cjs.map +1 -0
  13. package/dist/cjs/utils/create-client-name-regex.cjs +27 -0
  14. package/dist/cjs/utils/create-client-name-regex.cjs.map +1 -0
  15. package/dist/cjs/utils/get-docs-url.cjs +6 -0
  16. package/dist/cjs/utils/get-docs-url.cjs.map +1 -0
  17. package/dist/cjs/utils/get-root-identifier.cjs +22 -0
  18. package/dist/cjs/utils/get-root-identifier.cjs.map +1 -0
  19. package/dist/esm/index.js +37 -0
  20. package/dist/esm/index.js.map +1 -0
  21. package/dist/esm/rules/no-rest-destructuring/no-rest-destructuring.rule.js +113 -0
  22. package/dist/esm/rules/no-rest-destructuring/no-rest-destructuring.rule.js.map +1 -0
  23. package/dist/esm/rules/no-rest-destructuring/no-rest-destructuring.utils.js +13 -0
  24. package/dist/esm/rules/no-rest-destructuring/no-rest-destructuring.utils.js.map +1 -0
  25. package/dist/esm/rules/no-unstable-deps/no-unstable-deps.rule.js +121 -0
  26. package/dist/esm/rules/no-unstable-deps/no-unstable-deps.rule.js.map +1 -0
  27. package/dist/esm/rules.js +10 -0
  28. package/dist/esm/rules.js.map +1 -0
  29. package/dist/esm/utils/create-client-name-regex.js +25 -0
  30. package/dist/esm/utils/create-client-name-regex.js.map +1 -0
  31. package/dist/esm/utils/get-docs-url.js +4 -0
  32. package/dist/esm/utils/get-docs-url.js.map +1 -0
  33. package/dist/esm/utils/get-root-identifier.js +20 -0
  34. package/dist/esm/utils/get-root-identifier.js.map +1 -0
  35. package/dist/types/index.d.ts +23 -0
  36. package/dist/types/index.d.ts.map +1 -0
  37. package/dist/types/rules/no-rest-destructuring/no-rest-destructuring.rule.d.ts +5 -0
  38. package/dist/types/rules/no-rest-destructuring/no-rest-destructuring.rule.d.ts.map +1 -0
  39. package/dist/types/rules/no-rest-destructuring/no-rest-destructuring.utils.d.ts +5 -0
  40. package/dist/types/rules/no-rest-destructuring/no-rest-destructuring.utils.d.ts.map +1 -0
  41. package/dist/types/rules/no-unstable-deps/no-unstable-deps.rule.d.ts +7 -0
  42. package/dist/types/rules/no-unstable-deps/no-unstable-deps.rule.d.ts.map +1 -0
  43. package/dist/types/rules.d.ts +4 -0
  44. package/dist/types/rules.d.ts.map +1 -0
  45. package/dist/types/types.d.ts +4 -0
  46. package/dist/types/types.d.ts.map +1 -0
  47. package/dist/types/utils/create-client-name-regex.d.ts +2 -0
  48. package/dist/types/utils/create-client-name-regex.d.ts.map +1 -0
  49. package/dist/types/utils/get-docs-url.d.ts +2 -0
  50. package/dist/types/utils/get-docs-url.d.ts.map +1 -0
  51. package/dist/types/utils/get-root-identifier.d.ts +3 -0
  52. package/dist/types/utils/get-root-identifier.d.ts.map +1 -0
  53. package/package.json +67 -0
  54. package/src/index.ts +54 -0
  55. package/src/rules/no-rest-destructuring/no-rest-destructuring.rule.ts +135 -0
  56. package/src/rules/no-rest-destructuring/no-rest-destructuring.utils.ts +11 -0
  57. package/src/rules/no-unstable-deps/no-unstable-deps.rule.ts +137 -0
  58. package/src/rules.ts +17 -0
  59. package/src/types.ts +3 -0
  60. package/src/utils/create-client-name-regex.ts +32 -0
  61. package/src/utils/get-docs-url.ts +2 -0
  62. package/src/utils/get-root-identifier.ts +25 -0
@@ -0,0 +1,135 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import type { ExtraRuleDocs } from '../../types';
3
+ import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils';
4
+ import { createClientNameRegex } from '../../utils/create-client-name-regex';
5
+ import { getDocsUrl } from '../../utils/get-docs-url';
6
+ import { getRootIdentifier } from '../../utils/get-root-identifier';
7
+ import { NoRestDestructuringUtils } from './no-rest-destructuring.utils';
8
+
9
+ export const name = 'no-rest-destructuring';
10
+
11
+ const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl);
12
+
13
+ const queryHooks = [
14
+ 'useQuery',
15
+ 'useQueries',
16
+ 'useInfiniteQuery',
17
+ 'useSuspenseQuery',
18
+ 'useSuspenseQueries',
19
+ 'useSuspenseInfiniteQuery',
20
+ ];
21
+
22
+ export const rule = createRule({
23
+ name,
24
+ meta: {
25
+ type: 'problem',
26
+ docs: {
27
+ description: 'Disallows rest destructuring in queries',
28
+ recommended: 'warn',
29
+ },
30
+ messages: {
31
+ objectRestDestructure: `Object rest destructuring on a query will observe all changes to the query, leading to excessive re-renders.`,
32
+ },
33
+ schema: [
34
+ {
35
+ type: 'object',
36
+ properties: {
37
+ clientNamePattern: { type: 'string' },
38
+ },
39
+ additionalProperties: false,
40
+ },
41
+ ],
42
+ },
43
+ defaultOptions: [{}],
44
+
45
+ create: (context) => {
46
+ const option =
47
+ (context.options[0] as { clientNamePattern?: string } | undefined) ?? {};
48
+
49
+ const clientRegex = createClientNameRegex(option.clientNamePattern);
50
+
51
+ const queryResultVariables = new Set<string>();
52
+
53
+ return {
54
+ CallExpression: (node) => {
55
+ if (node.parent.type !== AST_NODE_TYPES.VariableDeclarator) return;
56
+
57
+ const callee = node.callee;
58
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) return;
59
+ const hookName = getHookNameIfTargetFromClient(clientRegex, callee);
60
+ if (!hookName) return;
61
+ const returnValue = node.parent.id;
62
+
63
+ if (hookName !== 'useQueries' && hookName !== 'useSuspenseQueries') {
64
+ if (NoRestDestructuringUtils.isObjectRestDestructuring(returnValue)) {
65
+ return context.report({
66
+ node: node.parent,
67
+ messageId: 'objectRestDestructure',
68
+ });
69
+ }
70
+
71
+ if (returnValue.type === AST_NODE_TYPES.Identifier) {
72
+ queryResultVariables.add(returnValue.name);
73
+ }
74
+
75
+ return;
76
+ }
77
+
78
+ if (returnValue.type !== AST_NODE_TYPES.ArrayPattern) {
79
+ if (returnValue.type === AST_NODE_TYPES.Identifier) {
80
+ queryResultVariables.add(returnValue.name);
81
+ }
82
+ return;
83
+ }
84
+
85
+ returnValue.elements.forEach((queryResult) => {
86
+ if (queryResult === null) {
87
+ return;
88
+ }
89
+ if (NoRestDestructuringUtils.isObjectRestDestructuring(queryResult)) {
90
+ context.report({
91
+ node: queryResult,
92
+ messageId: 'objectRestDestructure',
93
+ });
94
+ }
95
+ });
96
+ },
97
+
98
+ VariableDeclarator: (node) => {
99
+ if (
100
+ node.init?.type === AST_NODE_TYPES.Identifier &&
101
+ queryResultVariables.has(node.init.name) &&
102
+ NoRestDestructuringUtils.isObjectRestDestructuring(node.id)
103
+ ) {
104
+ context.report({
105
+ node,
106
+ messageId: 'objectRestDestructure',
107
+ });
108
+ }
109
+ },
110
+
111
+ SpreadElement: (node) => {
112
+ if (
113
+ node.argument.type === AST_NODE_TYPES.Identifier &&
114
+ queryResultVariables.has(node.argument.name)
115
+ ) {
116
+ context.report({
117
+ node,
118
+ messageId: 'objectRestDestructure',
119
+ });
120
+ }
121
+ },
122
+ };
123
+ },
124
+ });
125
+
126
+ function getHookNameIfTargetFromClient(
127
+ clientRegex: RegExp,
128
+ callee: TSESTree.MemberExpression
129
+ ): string | undefined {
130
+ if (callee.property.type !== AST_NODE_TYPES.Identifier) return;
131
+ const name = callee.property.name;
132
+ if (!queryHooks.includes(name)) return;
133
+ const root = getRootIdentifier(callee.object);
134
+ return root && clientRegex.test(root.name) ? name : undefined;
135
+ }
@@ -0,0 +1,11 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+
4
+ export const NoRestDestructuringUtils = {
5
+ isObjectRestDestructuring(node: TSESTree.Node): boolean {
6
+ if (node.type !== AST_NODE_TYPES.ObjectPattern) {
7
+ return false;
8
+ }
9
+ return node.properties.some((p) => p.type === AST_NODE_TYPES.RestElement);
10
+ },
11
+ };
@@ -0,0 +1,137 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import type { ExtraRuleDocs } from '../../types';
3
+ import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils';
4
+ import { createClientNameRegex } from '../../utils/create-client-name-regex';
5
+ import { getDocsUrl } from '../../utils/get-docs-url';
6
+ import { getRootIdentifier } from '../../utils/get-root-identifier';
7
+
8
+ export const name = 'no-unstable-deps';
9
+
10
+ export const reactHookNames = ['useEffect', 'useCallback', 'useMemo'];
11
+ export const useQueryHookNames = [
12
+ 'useQuery',
13
+ 'useSuspenseQuery',
14
+ 'useQueries',
15
+ 'useSuspenseQueries',
16
+ 'useInfiniteQuery',
17
+ 'useSuspenseInfiniteQuery',
18
+ ];
19
+ const allHookNames = ['useMutation', ...useQueryHookNames];
20
+ const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl);
21
+
22
+ export const rule = createRule({
23
+ name,
24
+ meta: {
25
+ type: 'problem',
26
+ docs: {
27
+ description:
28
+ 'Disallow putting the result of query hooks directly in a React hook dependency array',
29
+ recommended: 'error',
30
+ },
31
+ messages: {
32
+ 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}}.`,
33
+ },
34
+ schema: [
35
+ {
36
+ type: 'object',
37
+ properties: {
38
+ clientNamePattern: { type: 'string' },
39
+ },
40
+ additionalProperties: false,
41
+ },
42
+ ],
43
+ },
44
+ defaultOptions: [{}],
45
+
46
+ create: (context) => {
47
+ const option =
48
+ (context.options[0] as { clientNamePattern?: string } | undefined) ?? {};
49
+ const clientRegex = createClientNameRegex(option.clientNamePattern);
50
+
51
+ const trackedVariables: Record<string, string> = {};
52
+ const hookAliasMap: Record<string, string> = {};
53
+
54
+ function getReactHook(node: TSESTree.CallExpression): string | undefined {
55
+ if (node.callee.type === 'Identifier') {
56
+ const calleeName = node.callee.name;
57
+ if (reactHookNames.includes(calleeName) || calleeName in hookAliasMap) {
58
+ // Return the actually used identifier (could be alias)
59
+ return calleeName;
60
+ }
61
+ } else if (
62
+ node.callee.type === 'MemberExpression' &&
63
+ node.callee.object.type === 'Identifier' &&
64
+ node.callee.object.name === 'React' &&
65
+ node.callee.property.type === 'Identifier' &&
66
+ reactHookNames.includes(node.callee.property.name)
67
+ ) {
68
+ return node.callee.property.name;
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ return {
74
+ ImportDeclaration(node: TSESTree.ImportDeclaration) {
75
+ if (
76
+ node.specifiers.length > 0 &&
77
+ node.importKind === 'value' &&
78
+ node.source.value === 'React'
79
+ ) {
80
+ node.specifiers.forEach((specifier) => {
81
+ if (
82
+ specifier.type === AST_NODE_TYPES.ImportSpecifier &&
83
+ specifier.imported.type === AST_NODE_TYPES.Identifier &&
84
+ reactHookNames.includes(specifier.imported.name)
85
+ ) {
86
+ hookAliasMap[specifier.local.name] = specifier.imported.name;
87
+ }
88
+ });
89
+ }
90
+ },
91
+
92
+ VariableDeclarator(node) {
93
+ if (node.init?.type !== AST_NODE_TYPES.CallExpression) return;
94
+ const hook = isQueryHookFromClient(clientRegex, node.init);
95
+ if (!hook) return;
96
+ if (node.id.type === AST_NODE_TYPES.Identifier) {
97
+ trackedVariables[node.id.name] = hook;
98
+ }
99
+ },
100
+ CallExpression: (node) => {
101
+ const reactHook = getReactHook(node);
102
+ if (!reactHook) return;
103
+ if (node.arguments.length <= 1) return;
104
+ if (node.arguments[1]?.type !== AST_NODE_TYPES.ArrayExpression) return;
105
+
106
+ const depsArray = node.arguments[1].elements;
107
+ depsArray.forEach((dep) => {
108
+ if (dep?.type !== AST_NODE_TYPES.Identifier) return;
109
+ const queryHook = trackedVariables[dep.name];
110
+ if (!queryHook) return;
111
+ context.report({
112
+ node: dep,
113
+ messageId: 'noUnstableDeps',
114
+ data: {
115
+ queryHook,
116
+ reactHook,
117
+ },
118
+ });
119
+ });
120
+ },
121
+ };
122
+ },
123
+ });
124
+
125
+ function isQueryHookFromClient(
126
+ clientRegex: RegExp,
127
+ call: TSESTree.CallExpression
128
+ ): string | null {
129
+ if (call.callee.type !== AST_NODE_TYPES.MemberExpression) return null;
130
+ const member = call.callee;
131
+ if (member.property.type !== AST_NODE_TYPES.Identifier) return null;
132
+ const hook = member.property.name;
133
+ if (!allHookNames.includes(hook)) return null;
134
+ const root = getRootIdentifier(member.object);
135
+ if (!root) return null;
136
+ return clientRegex.test(root.name) ? hook : null;
137
+ }
package/src/rules.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { ESLintUtils } from '@typescript-eslint/utils';
2
+ import type { ExtraRuleDocs } from './types';
3
+ import * as noRestDestructuring from './rules/no-rest-destructuring/no-rest-destructuring.rule';
4
+ import * as noUnstableDeps from './rules/no-unstable-deps/no-unstable-deps.rule';
5
+
6
+ export const rules: Record<
7
+ string,
8
+ ESLintUtils.RuleModule<
9
+ string,
10
+ ReadonlyArray<unknown>,
11
+ ExtraRuleDocs,
12
+ ESLintUtils.RuleListener
13
+ >
14
+ > = {
15
+ [noRestDestructuring.name]: noRestDestructuring.rule,
16
+ [noUnstableDeps.name]: noUnstableDeps.rule,
17
+ };
package/src/types.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type ExtraRuleDocs = {
2
+ recommended: 'strict' | 'error' | 'warn';
3
+ };
@@ -0,0 +1,32 @@
1
+ export function createClientNameRegex(clientNamePattern: string | undefined) {
2
+ clientNamePattern = clientNamePattern?.trim();
3
+
4
+ if (clientNamePattern) {
5
+ // Check if pattern is in regex format: /pattern/flags
6
+ const regexMatch = clientNamePattern.match(/^\/(.*)\/([gimuy]*)$/);
7
+
8
+ if (regexMatch) {
9
+ const regexPattern = regexMatch[1].trim();
10
+ const flags = regexMatch[2];
11
+
12
+ // Validate that regex pattern is not empty or whitespace-only
13
+ if (regexPattern === '') {
14
+ throw new Error(
15
+ 'clientNamePattern cannot be empty or contain only whitespace'
16
+ );
17
+ }
18
+
19
+ return new RegExp(regexPattern, flags);
20
+ }
21
+
22
+ // Fallback to treating as plain string pattern
23
+ return new RegExp(clientNamePattern);
24
+ } else if (clientNamePattern === '') {
25
+ throw new Error(
26
+ 'clientNamePattern cannot be empty or contain only whitespace'
27
+ );
28
+ }
29
+
30
+ // Default if no pattern provided
31
+ return /qraft|api/i;
32
+ }
@@ -0,0 +1,2 @@
1
+ export const getDocsUrl = (ruleName: string): string =>
2
+ `https://openapi-qraft.github.io/openapi-qraft/docs/eslint/${ruleName}`;
@@ -0,0 +1,25 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3
+
4
+ export function getRootIdentifier(
5
+ node: TSESTree.Node
6
+ ): TSESTree.Identifier | undefined {
7
+ let current: TSESTree.Node | undefined = node;
8
+ while (
9
+ current.type === AST_NODE_TYPES.MemberExpression ||
10
+ current.type === AST_NODE_TYPES.TSNonNullExpression ||
11
+ current.type === AST_NODE_TYPES.ChainExpression
12
+ ) {
13
+ if (current.type === AST_NODE_TYPES.MemberExpression) {
14
+ current = current.object;
15
+ continue;
16
+ }
17
+ if (current.type === AST_NODE_TYPES.TSNonNullExpression) {
18
+ current = current.expression;
19
+ continue;
20
+ }
21
+
22
+ current = current.expression;
23
+ }
24
+ return current.type === AST_NODE_TYPES.Identifier ? current : undefined;
25
+ }