diff --git a/.changeset/exhaustive-deps-allowlist.md b/.changeset/exhaustive-deps-allowlist.md new file mode 100644 index 00000000000..c20293272ae --- /dev/null +++ b/.changeset/exhaustive-deps-allowlist.md @@ -0,0 +1,5 @@ +--- +'@tanstack/eslint-plugin-query': minor +--- + +BREAKING (eslint-plugin): The `exhaustive-deps` rule now reports member expression dependencies more granularly for call expressions (e.g. `a.b.foo()` suggests `a.b`), which may cause existing code that previously passed the rule to now report missing dependencies. To accommodate stable variables and types, the rule now accepts an `allowlist` option with `variables` and `types` arrays to exclude specific dependencies from enforcement. diff --git a/docs/eslint/exhaustive-deps.md b/docs/eslint/exhaustive-deps.md index a4b1bd12147..3fa56f1a54f 100644 --- a/docs/eslint/exhaustive-deps.md +++ b/docs/eslint/exhaustive-deps.md @@ -26,13 +26,64 @@ const todoQueries = { Examples of **correct** code for this rule: ```tsx -useQuery({ - queryKey: ['todo', todoId], - queryFn: () => api.getTodo(todoId), -}) +const Component = ({ todoId }) => { + const todos = useTodos() + useQuery({ + queryKey: ['todo', todos, todoId], + queryFn: () => todos.getTodo(todoId), + }) +} +``` +```tsx +const todos = createTodos() const todoQueries = { - detail: (id) => ({ queryKey: ['todo', id], queryFn: () => api.getTodo(id) }), + detail: (id) => ({ + queryKey: ['todo', id], + queryFn: () => todos.getTodo(id), + }), +} +``` + +```tsx +// with { allowlist: { variables: ["todos"] }} +const Component = ({ todoId }) => { + const todos = useTodos() + useQuery({ + queryKey: ['todo', todoId], + queryFn: () => todos.getTodo(todoId), + }) +} +``` + +```tsx +// with { allowlist: { types: ["TodosClient"] }} +class TodosClient { ... } +const Component = ({ todoId }) => { + const todos: TodosClient = new TodosClient() + useQuery({ + queryKey: ['todo', todoId], + queryFn: () => todos.getTodo(todoId), + }) +} +``` + +### Options + +- `allowlist.variables`: An array of variable names that should be ignored when checking dependencies +- `allowlist.types`: An array of TypeScript type names that should be ignored when checking dependencies + +```json +{ + "@tanstack/query/exhaustive-deps": [ + "error", + { + "allowlist": { + "variables": ["api", "config"], + "types": ["ApiClient", "Config"] + } + } + ] } ``` diff --git a/examples/react/eslint-plugin-demo/eslint.config.js b/examples/react/eslint-plugin-demo/eslint.config.js new file mode 100644 index 00000000000..1e9a5820b5d --- /dev/null +++ b/examples/react/eslint-plugin-demo/eslint.config.js @@ -0,0 +1,21 @@ +import pluginQuery from '@tanstack/eslint-plugin-query' +import tseslint from 'typescript-eslint' + +export default [ + ...tseslint.configs.recommended, + ...pluginQuery.configs['flat/recommended'], + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + rules: { + '@tanstack/query/exhaustive-deps': [ + 'error', + { + allowlist: { + variables: ['api'], + types: ['AnalyticsClient'], + }, + }, + ], + }, + }, +] diff --git a/examples/react/eslint-plugin-demo/package.json b/examples/react/eslint-plugin-demo/package.json new file mode 100644 index 00000000000..7909733e37e --- /dev/null +++ b/examples/react/eslint-plugin-demo/package.json @@ -0,0 +1,27 @@ +{ + "name": "@tanstack/query-example-eslint-plugin-demo", + "private": true, + "type": "module", + "scripts": { + "test:eslint": "eslint ./src" + }, + "dependencies": { + "@tanstack/react-query": "^5.91.0", + "react": "^19.0.0" + }, + "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.91.5", + "eslint": "^9.39.0", + "typescript": "5.8.3", + "typescript-eslint": "^8.48.0" + }, + "nx": { + "targets": { + "test:eslint": { + "dependsOn": [ + "^build" + ] + } + } + } +} diff --git a/examples/react/eslint-plugin-demo/src/allowlist-demo.tsx b/examples/react/eslint-plugin-demo/src/allowlist-demo.tsx new file mode 100644 index 00000000000..8960d3c2223 --- /dev/null +++ b/examples/react/eslint-plugin-demo/src/allowlist-demo.tsx @@ -0,0 +1,55 @@ +import { queryOptions } from '@tanstack/react-query' + +export function todosOptions(userId: string) { + const api = useApiClient() + return queryOptions({ + // ✅ passes: 'api' is in allowlist.variables + queryKey: ['todos', userId], + queryFn: () => api.fetchTodos(userId), + }) +} + +export function todosByApiOptions(userId: string) { + const todoApi = useApiClient() + // ❌ fails: 'api' is in allowlist.variables, but this variable is named 'todoApi' + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- The following dependencies are missing in your queryKey: todoApi + return queryOptions({ + queryKey: ['todos', userId], + queryFn: () => todoApi.fetchTodos(userId), + }) +} + +export function todosWithTrackingOptions( + tracker: AnalyticsClient, + userId: string, +) { + return queryOptions({ + // ✅ passes: AnalyticsClient is in allowlist.types + queryKey: ['todos', userId], + queryFn: async () => { + tracker.track('todos') + return fetch(`/api/todos?userId=${userId}`).then((r) => r.json()) + }, + }) +} + +export function todosWithClientOptions(client: ApiClient, userId: string) { + // ❌ fails: AnalyticsClient is in allowlist.types, but this param is typed as ApiClient + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- The following dependencies are missing in your queryKey: client + return queryOptions({ + queryKey: ['todos', userId], + queryFn: () => client.fetchTodos(userId), + }) +} + +interface ApiClient { + fetchTodos: (userId: string) => Promise> +} + +interface AnalyticsClient { + track: (event: string) => Promise +} + +function useApiClient(): ApiClient { + throw new Error('not implemented') +} diff --git a/examples/react/eslint-plugin-demo/tsconfig.json b/examples/react/eslint-plugin-demo/tsconfig.json new file mode 100644 index 00000000000..afa57cffd5d --- /dev/null +++ b/examples/react/eslint-plugin-demo/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src", "eslint.config.js"] +} diff --git a/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts b/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts index d16ee89d54d..0ad090d1c4c 100644 --- a/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/exhaustive-deps.test.ts @@ -34,6 +34,20 @@ ruleTester.run('exhaustive-deps', rule, { name: 'should not pass api.entity.get', code: 'useQuery({ queryKey: ["entity", id], queryFn: () => api.entity.get(id) });', }, + { + name: 'should pass api when its member is being invoked', + code: ` + import useApi from './useApi' + + const useFoo = () => { + const api = useApi(); + return useQuery({ + queryKey: ['foo', api], + queryFn: () => api.fetchFoo(), + }) + } + `, + }, { name: 'should pass props.src', code: ` @@ -476,7 +490,7 @@ ruleTester.run('exhaustive-deps', rule, { function Component({ value }) { useQuery({ - queryKey: ['foo'], + queryKey: ['foo', value], queryFn: () => { return value instanceof SomeClass; } @@ -682,37 +696,201 @@ ruleTester.run('exhaustive-deps', rule, { } `, }, + { + name: 'should pass when multiple sibling member method calls covered by root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a], + queryFn: () => { + a.b.foo() + a.c.bar() + return 1 + } + }) + } + `, + }, + { + name: 'should pass when multiple sibling member method calls explicitly listed', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b, a.c], + queryFn: () => { + a.b.foo() + a.c.bar() + return 1 + } + }) + } + `, + }, + { + name: 'should pass when single member method call covered by root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a], + queryFn: () => { + a.b.foo() + return 1 + } + }) + } + `, + }, + { + name: 'should pass when single member method call uses member path', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b], + queryFn: () => { + a.b.foo() + return 1 + } + }) + } + `, + }, + { + name: 'should pass when optional chaining method call is covered by root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a], + queryFn: () => a?.foo() + }) + } + `, + }, + { + name: 'should pass when queryKey uses TSAsExpression with array', + code: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing', dep] as const, + queryFn: () => dep + }) + } + `, + }, + { + name: 'should pass when queryKey references identifier pointing to array', + code: normalizeIndent` + function useThing(dep) { + const key = ['thing', dep] + return useQuery({ + queryKey: key, + queryFn: () => dep + }) + } + `, + }, + { + name: 'should pass when queryKey has object with spread properties', + code: normalizeIndent` + function useThing(dep1, dep2) { + return useQuery({ + queryKey: ['thing', { ...dep1, prop: dep2 }], + queryFn: () => dep1.prop + dep2 + }) + } + `, + }, + { + name: 'should pass when queryKey has call expression with member callee', + code: normalizeIndent` + function useThing(api) { + return useQuery({ + queryKey: ['thing', api.createKey()], + queryFn: () => api.fetch() + }) + } + `, + }, + { + name: 'should pass when queryKey has call expression with nested member callee', + code: normalizeIndent` + function useThing(obj) { + return useQuery({ + queryKey: ['thing', obj.api.createKey()], + queryFn: () => obj.api.fetch() + }) + } + `, + }, + { + name: 'should pass when queryFn uses conditional with skipToken', + code: normalizeIndent` + function useThing(condition, dep) { + return useQuery({ + queryKey: ['thing', dep], + queryFn: condition ? () => dep : skipToken + }) + } + `, + }, + { + name: 'should pass when queryFn uses instanceof expression', + code: normalizeIndent` + function useThing(value) { + return useQuery({ + queryKey: ['thing', value], + queryFn: () => { + return value instanceof Date; + } + }) + } + `, + }, + { + name: 'should pass when queryFn uses conditional with skipToken in consequent', + code: normalizeIndent` + function useThing(condition, dep) { + return useQuery({ + queryKey: ['thing', dep], + queryFn: condition ? skipToken : () => dep + }) + } + `, + }, + { + name: 'should pass when queryFn is ternary with both branches having deps in queryKey', + code: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', a, b], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + }, ], invalid: [ { - name: 'should fail when api from hook is used for calling a function', + name: 'should fail when optional chaining method call is missing root', code: normalizeIndent` - import useApi from './useApi' - - const useFoo = () => { - const api = useApi(); + function useThing(a) { return useQuery({ - queryKey: ['foo'], - queryFn: () => api.fetchFoo(), + queryKey: ['thing'], + queryFn: () => a?.foo() }) } `, errors: [ { messageId: 'missingDeps', - data: { deps: 'api' }, + data: { deps: 'a' }, suggestions: [ { messageId: 'fixTo', - data: { result: "['foo', api]" }, output: normalizeIndent` - import useApi from './useApi' - - const useFoo = () => { - const api = useApi(); + function useThing(a) { return useQuery({ - queryKey: ['foo', api], - queryFn: () => api.fetchFoo(), + queryKey: ['thing', a], + queryFn: () => a?.foo() }) } `, @@ -721,6 +899,79 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, + { + name: 'should fail when non-null assertion method call is missing root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing'], + queryFn: () => a!.foo() + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'a' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a], + queryFn: () => a!.foo() + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when alias of props used in queryFn is missing in queryKey', + code: normalizeIndent` + function Component(props) { + const entities = props.entities; + + const q = useQuery({ + queryKey: ['get-stuff'], + queryFn: () => { + return api.fetchStuff({ + ids: entities.map((o) => o.id) + }); + } + }); + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'entities' }, + suggestions: [ + { + messageId: 'fixTo', + data: { result: "['get-stuff', entities]" }, + output: normalizeIndent` + function Component(props) { + const entities = props.entities; + + const q = useQuery({ + queryKey: ['get-stuff', entities], + queryFn: () => { + return api.fetchStuff({ + ids: entities.map((o) => o.id) + }); + } + }); + } + `, + }, + ], + }, + ], + }, { name: 'should fail when deps are missing in query factory', code: normalizeIndent` @@ -1020,49 +1271,6 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, - { - name: 'should fail when alias of props used in queryFn is missing in queryKey', - code: normalizeIndent` - function Component(props) { - const entities = props.entities; - - const q = useQuery({ - queryKey: ['get-stuff'], - queryFn: () => { - return api.fetchStuff({ - ids: entities.map((o) => o.id) - }); - } - }); - } - `, - errors: [ - { - messageId: 'missingDeps', - data: { deps: 'entities' }, - suggestions: [ - { - messageId: 'fixTo', - data: { result: "['get-stuff', entities]" }, - output: normalizeIndent` - function Component(props) { - const entities = props.entities; - - const q = useQuery({ - queryKey: ['get-stuff', entities], - queryFn: () => { - return api.fetchStuff({ - ids: entities.map((o) => o.id) - }); - } - }); - } - `, - }, - ], - }, - ], - }, { name: 'should fail when queryKey is a queryKeyFactory while having missing dep', code: normalizeIndent` @@ -1399,5 +1607,611 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, + { + name: 'should fail when sibling member method calls missing one path', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b], + queryFn: () => { + a.b.foo() + a.c.bar() + return 1 + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'a.c' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b, a.c], + queryFn: () => { + a.b.foo() + a.c.bar() + return 1 + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when single member method call missing path and root', + code: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing'], + queryFn: () => { + a.b.foo() + return 1 + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'a.b' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(a) { + return useQuery({ + queryKey: ['thing', a.b], + queryFn: () => { + a.b.foo() + return 1 + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when queryKey has TSAsExpression with missing dep', + code: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing'] as const, + queryFn: () => dep + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'dep' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing', dep] as const, + queryFn: () => dep + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when queryKey references identifier with missing dep', + code: normalizeIndent` + function useThing(dep) { + const key = ['thing'] + return useQuery({ + queryKey: key, + queryFn: () => dep + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'dep' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(dep) { + const key = ['thing', dep] + return useQuery({ + queryKey: key, + queryFn: () => dep + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when type allowlist is empty', + options: [{ allowlist: { types: [] } }], + code: normalizeIndent` + interface Api { fetch: () => void } + function useThing(api: Api) { + return useQuery({ + queryKey: ['thing'], + queryFn: () => api.fetch() + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'api' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + interface Api { fetch: () => void } + function useThing(api: Api) { + return useQuery({ + queryKey: ['thing', api], + queryFn: () => api.fetch() + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fix correctly when queryKey has trailing comma', + code: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing',], + queryFn: () => dep + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'dep' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: ['thing', dep], + queryFn: () => dep + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fix correctly when queryKey is empty with whitespace', + code: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: [ ], + queryFn: () => dep + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'dep' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(dep) { + return useQuery({ + queryKey: [dep], + queryFn: () => dep + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when dep in alternate branch of ternary queryFn is missing', + code: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', a], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'b' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', a, b], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when dep in consequent branch of ternary queryFn is missing', + code: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', b], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'a' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(condition, a, b) { + return useQuery({ + queryKey: ['thing', b, a], + queryFn: condition ? () => fetchA(a) : () => fetchB(b) + }) + } + `, + }, + ], + }, + ], + }, + ], +}) + +ruleTester.run('exhaustive-deps allowlist.types', rule, { + valid: [ + { + name: 'should ignore missing member path when root type is in allowlist.types', + options: [{ allowlist: { types: ['Svc'] } }], + code: normalizeIndent` + interface Svc { part: { load: (id: string) => void } } + function useThing(svc: Svc, id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + }, + { + name: 'should ignore when TypeScript union type contains allowlisted type', + options: [{ allowlist: { types: ['AllowedType'] } }], + code: normalizeIndent` + function useThing(value: AllowedType | OtherType, id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + console.log(value) + return id + } + }) + } + `, + }, + { + name: 'should ignore when TypeScript intersection type contains allowlisted type', + options: [{ allowlist: { types: ['AllowedType'] } }], + code: normalizeIndent` + function useThing(value: AllowedType & OtherType, id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + console.log(value) + return id + } + }) + } + `, + }, + { + name: 'should ignore when TypeScript array type contains allowlisted type', + options: [{ allowlist: { types: ['AllowedType'] } }], + code: normalizeIndent` + function useThing(value: AllowedType[], id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + console.log(value) + return id + } + }) + } + `, + }, + { + name: 'should ignore when TypeScript tuple type contains allowlisted type', + options: [{ allowlist: { types: ['AllowedType'] } }], + code: normalizeIndent` + function useThing(value: [AllowedType, string], id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + console.log(value) + return id + } + }) + } + `, + }, + ], + invalid: [ + { + name: 'should report missing member path when root type not in allowlist.types', + options: [{ allowlist: { types: ['Other'] } }], + code: normalizeIndent` + interface Svc { part: { load: (id: string) => void } } + function useThing(svc: Svc, id: string) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'svc.part' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + interface Svc { part: { load: (id: string) => void } } + function useThing(svc: Svc, id: string) { + return useQuery({ + queryKey: ['thing', id, svc.part], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should report missing member path when variable has type annotation but type not allowlisted', + options: [{ allowlist: { types: ['AllowedService'] } }], + code: normalizeIndent` + interface MyService { method: () => void } + function useData(service: MyService) { + return useQuery({ + queryKey: ['data'], + queryFn: () => { + service.method() + return 'data' + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'service' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + interface MyService { method: () => void } + function useData(service: MyService) { + return useQuery({ + queryKey: ['data', service], + queryFn: () => { + service.method() + return 'data' + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should not inherit allowlisted type from outer shadowed binding', + options: [{ allowlist: { types: ['AllowedService'] } }], + code: normalizeIndent` + interface AllowedService { load: () => void } + interface OtherService { load: () => void } + + function useThing() { + const svc: AllowedService = { load: () => undefined } + + if (true) { + const svc: OtherService = { load: () => undefined } + + return useQuery({ + queryKey: ['thing'], + queryFn: () => { + svc.load() + return 'data' + } + }) + } + + return null + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'svc' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + interface AllowedService { load: () => void } + interface OtherService { load: () => void } + + function useThing() { + const svc: AllowedService = { load: () => undefined } + + if (true) { + const svc: OtherService = { load: () => undefined } + + return useQuery({ + queryKey: ['thing', svc], + queryFn: () => { + svc.load() + return 'data' + } + }) + } + + return null + } + `, + }, + ], + }, + ], + }, + ], +}) + +ruleTester.run('exhaustive-deps allowlist.variables', rule, { + valid: [ + { + name: 'should ignore missing member path when root is in allowlist.variables', + options: [{ allowlist: { variables: ['svc'] } }], + code: normalizeIndent` + function useThing(svc, id) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + }, + ], + invalid: [ + { + name: 'should only report non-allowlisted roots', + options: [{ allowlist: { variables: ['svc'] } }], + code: normalizeIndent` + function useThing(svc, other) { + return useQuery({ + queryKey: ['thing'], + queryFn: () => { + svc.part.load() + other.x.run() + return 1 + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'other.x' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(svc, other) { + return useQuery({ + queryKey: ['thing', other.x], + queryFn: () => { + svc.part.load() + other.x.run() + return 1 + } + }) + } + `, + }, + ], + }, + ], + }, + { + name: 'should fail when missing member path not in allowlist.variables', + code: normalizeIndent` + function useThing(svc, id) { + return useQuery({ + queryKey: ['thing', id], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'svc.part' }, + suggestions: [ + { + messageId: 'fixTo', + output: normalizeIndent` + function useThing(svc, id) { + return useQuery({ + queryKey: ['thing', id, svc.part], + queryFn: () => { + svc.part.load(id) + return id + } + }) + } + `, + }, + ], + }, + ], + }, ], }) diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts index 2f20877c67e..a3e7e849254 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts @@ -1,7 +1,6 @@ import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' import { ASTUtils } from '../../utils/ast-utils' import { getDocsUrl } from '../../utils/get-docs-url' -import { uniqueBy } from '../../utils/unique-by' import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports' import { ExhaustiveDepsUtils } from './exhaustive-deps.utils' import type { TSESLint, TSESTree } from '@typescript-eslint/utils' @@ -14,6 +13,13 @@ export const name = 'exhaustive-deps' const createRule = ESLintUtils.RuleCreator(getDocsUrl) +type RuleOption = { + allowlist?: { + variables?: Array + types?: Array + } +} + export const rule = createRule({ name, meta: { @@ -28,27 +34,36 @@ export const rule = createRule({ }, hasSuggestions: true, fixable: 'code', - schema: [], + schema: [ + { + type: 'object', + properties: { + allowlist: { + type: 'object', + properties: { + variables: { type: 'array', items: { type: 'string' } }, + types: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + ], }, defaultOptions: [], create: detectTanstackQueryImports((context) => { return { - Property: (node) => { - if ( - !ASTUtils.isObjectExpression(node.parent) || - !ASTUtils.isIdentifierWithName(node.key, QUERY_KEY) - ) { - return - } - + ObjectExpression: (node: TSESTree.ObjectExpression) => { const scopeManager = context.sourceCode.scopeManager + const queryKey = ASTUtils.findPropertyWithIdentifierKey( - node.parent.properties, + node.properties, QUERY_KEY, ) const queryFn = ASTUtils.findPropertyWithIdentifierKey( - node.parent.properties, + node.properties, QUERY_FN, ) @@ -70,103 +85,134 @@ export const rule = createRule({ context, ) - const externalRefs = ASTUtils.getExternalRefs({ - scopeManager, - sourceCode: context.sourceCode, - node: getQueryFnRelevantNode(queryFn), - }) + const queryFnNodes = ExhaustiveDepsUtils.getQueryFnNodes(queryFn) - const relevantRefs = externalRefs.filter((reference) => - ExhaustiveDepsUtils.isRelevantReference({ - sourceCode: context.sourceCode, - reference, + const externalRefs = queryFnNodes.flatMap((fnNode) => + ASTUtils.getExternalRefs({ scopeManager, - node: getQueryFnRelevantNode(queryFn), - filename: context.filename, + sourceCode: context.sourceCode, + node: fnNode, }), ) + const relevantRefs = externalRefs.filter((reference) => + queryFnNodes.some((fnNode) => + ExhaustiveDepsUtils.isRelevantReference({ + sourceCode: context.sourceCode, + reference, + scopeManager, + node: fnNode, + filename: context.filename, + }), + ), + ) + + const ruleOptions = context.options.at(0) as RuleOption | undefined + const allowlistedVariables = new Set( + ruleOptions?.allowlist?.variables ?? [], + ) + const allowlistedTypes = new Set(ruleOptions?.allowlist?.types ?? []) + + const requiredRefs = relevantRefs.flatMap((ref) => { + if (ref.identifier.type !== AST_NODE_TYPES.Identifier) return [] + + const refPath = ExhaustiveDepsUtils.computeRefPath({ + identifier: ref.identifier, + sourceCode: context.sourceCode, + }) + + if (refPath === null) return [] + + return [ + { + ...refPath, + allowlistedByType: + ExhaustiveDepsUtils.variableIsAllowlistedByType({ + allowlistedTypes, + variable: ref.resolved ?? null, + }), + }, + ] + }) + + if (requiredRefs.length === 0) return + const queryKeyDeps = ExhaustiveDepsUtils.collectQueryKeyDeps({ sourceCode: context.sourceCode, - scopeManager, + scopeManager: scopeManager, + queryKeyNode: queryKeyNode, + }) + + const missingPaths = ExhaustiveDepsUtils.computeFilteredMissingPaths({ + requiredRefs: requiredRefs, + allowlistedVariables: allowlistedVariables, + existingRootIdentifiers: queryKeyDeps.roots, + existingFullPaths: queryKeyDeps.paths, + }) + + if (missingPaths.length === 0) return + + const missingAsText = missingPaths.join(', ') + const suggestions = buildSuggestions({ queryKeyNode, + missingPaths, + missingAsText, + sourceCode: context.sourceCode, }) - const missingRefs = relevantRefs - .map((ref) => ({ - ref: ref, - text: ASTUtils.isAncestorIsCallee(ref.identifier) - ? ref.identifier.name - : ASTUtils.mapKeyNodeToBaseText( - ref.identifier, - context.sourceCode, - ), - })) - .filter(({ ref, text }) => { - return ( - !ref.isTypeReference && - !queryKeyDeps.has(text) && - !queryKeyDeps.has(text.split(/[?.]/)[0] ?? '') - ) - }) - .map(({ ref, text }) => ({ - identifier: ref.identifier, - text: text, - })) - - const uniqueMissingRefs = uniqueBy(missingRefs, (x) => x.text) - - if (uniqueMissingRefs.length > 0) { - const missingAsText = uniqueMissingRefs - .map((ref) => ref.text) - .join(', ') - - const queryKeyValue = context.sourceCode.getText(queryKeyNode) - - const existingWithMissing = - queryKeyValue === '[]' - ? `[${missingAsText}]` - : queryKeyValue.replace(/\]$/, `, ${missingAsText}]`) - - const suggestions: TSESLint.ReportSuggestionArray = [] - - if (queryKeyNode.type === AST_NODE_TYPES.ArrayExpression) { - suggestions.push({ - messageId: 'fixTo', - data: { result: existingWithMissing }, - fix(fixer) { - return fixer.replaceText(queryKeyNode, existingWithMissing) - }, - }) - } - - context.report({ - node: node, - messageId: 'missingDeps', - data: { - deps: uniqueMissingRefs.map((ref) => ref.text).join(', '), - }, - suggest: suggestions, - }) - } + context.report({ + node, + messageId: 'missingDeps', + data: { deps: missingAsText }, + suggest: suggestions, + }) }, } }), }) -function getQueryFnRelevantNode(queryFn: TSESTree.Property) { - if (queryFn.value.type !== AST_NODE_TYPES.ConditionalExpression) { - return queryFn.value +function buildSuggestions(params: { + queryKeyNode: TSESTree.Node + missingPaths: Array + missingAsText: string + sourceCode: Readonly +}): TSESLint.ReportSuggestionArray { + const { queryKeyNode, missingPaths, missingAsText, sourceCode } = params + + if (queryKeyNode.type !== AST_NODE_TYPES.ArrayExpression) { + return [] } - if ( - queryFn.value.consequent.type === AST_NODE_TYPES.Identifier && - queryFn.value.consequent.name === 'skipToken' - ) { - return queryFn.value.alternate + const closingBracket = sourceCode.getLastToken(queryKeyNode) + if (!closingBracket) return [] + + const existingElements = queryKeyNode.elements + .filter((el): el is NonNullable => el !== null) + .map((el) => sourceCode.getText(el)) + + const resultText = `[${[...existingElements, ...missingPaths].join(', ')}]` + + if (queryKeyNode.elements.length === 0) { + return [ + { + messageId: 'fixTo', + data: { result: resultText }, + fix: (fixer) => fixer.replaceText(queryKeyNode, resultText), + }, + ] } - return queryFn.value.consequent + const tokenBefore = sourceCode.getTokenBefore(closingBracket) + const separator = tokenBefore?.value === ',' ? ' ' : ', ' + + return [ + { + messageId: 'fixTo', + data: { result: resultText }, + fix: (fixer) => + fixer.insertTextBefore(closingBracket, `${separator}${missingAsText}`), + }, + ] } function dereferenceVariablesAndTypeAssertions( diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts index 32167e92199..dcc215bded5 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.utils.ts @@ -59,24 +59,88 @@ export const ExhaustiveDepsUtils = { !ExhaustiveDepsUtils.isInstanceOfKind(reference.identifier.parent) ) }, - isInstanceOfKind(node: TSESTree.Node) { - return ( - node.type === AST_NODE_TYPES.BinaryExpression && - node.operator === 'instanceof' - ) + + /** + * Given required refs and existing queryKey entries, compute missing dependency paths + * respecting allowlisted variables and types. + */ + computeFilteredMissingPaths(params: { + requiredRefs: Array<{ + path: string + root: string + allowlistedByType: boolean + }> + allowlistedVariables: Set + existingRootIdentifiers: Set + existingFullPaths: Set + }): Array { + const { + requiredRefs, + allowlistedVariables, + existingRootIdentifiers, + existingFullPaths, + } = params + + const missingPaths = new Set() + + for (const { root, path, allowlistedByType } of requiredRefs) { + // If root itself is present in the key, it covers all members + if (existingRootIdentifiers.has(root)) continue + if (allowlistedVariables.has(root)) continue + if (existingFullPaths.has(path)) continue + if (allowlistedByType) continue + + missingPaths.add(path) + } + + // Collapse descendants: if a root is already missing, drop deeper paths + for (const path of missingPaths) { + const root = path.split('.')[0] + if (root !== path && root !== undefined && missingPaths.has(root)) { + missingPaths.delete(path) + } + } + + return Array.from(missingPaths) }, + /** + * Extract existing queryKey deps as root identifiers and full member paths. + */ collectQueryKeyDeps(params: { sourceCode: Readonly scopeManager: TSESLint.Scope.ScopeManager queryKeyNode: TSESTree.Node - }): Set { + }): { roots: Set; paths: Set } { const { sourceCode, scopeManager, queryKeyNode } = params - const deps = new Set() + const roots = new Set() + const paths = new Set() const visitorKeys = sourceCode.visitorKeys - function add(identifier: TSESTree.Identifier) { - deps.add(ASTUtils.mapKeyNodeToBaseText(identifier, sourceCode)) + function addRoot(name: string) { + const cleaned = ExhaustiveDepsUtils.normalizeChain(name) + roots.add(cleaned) + paths.add(cleaned) + } + function addFull(text: string) { + const cleaned = ExhaustiveDepsUtils.normalizeChain(text) + paths.add(cleaned) + } + function addRefPath( + refPath: { + path: string + root: string + coversRootMembers: boolean + } | null, + ) { + if (!refPath) return + + if (refPath.coversRootMembers) { + addRoot(refPath.root) + return + } + + addFull(refPath.path) } function visitChildren(node: TSESTree.Node): void { @@ -106,9 +170,15 @@ export const ExhaustiveDepsUtils = { if (!node) return switch (node.type) { - case AST_NODE_TYPES.Identifier: - add(node) + case AST_NODE_TYPES.Identifier: { + addRefPath( + ExhaustiveDepsUtils.computeRefPath({ + identifier: node, + sourceCode: sourceCode, + }), + ) return + } case AST_NODE_TYPES.ArrowFunctionExpression: case AST_NODE_TYPES.FunctionExpression: for (const reference of ExhaustiveDepsUtils.collectExternalRefsInFunction( @@ -117,9 +187,16 @@ export const ExhaustiveDepsUtils = { scopeManager: scopeManager, }, )) { - if (reference.identifier.type === AST_NODE_TYPES.Identifier) { - add(reference.identifier) + if (reference.identifier.type !== AST_NODE_TYPES.Identifier) { + continue } + + addRefPath( + ExhaustiveDepsUtils.computeRefPath({ + identifier: reference.identifier, + sourceCode: sourceCode, + }), + ) } return case AST_NODE_TYPES.Property: @@ -131,7 +208,7 @@ export const ExhaustiveDepsUtils = { node.parent.callee === node && node.object.type === AST_NODE_TYPES.Identifier ) { - deps.add(node.object.name) + addRoot(node.object.name) } else { visit(node.object) } @@ -153,7 +230,7 @@ export const ExhaustiveDepsUtils = { visit(queryKeyNode) - return deps + return { roots, paths } }, isNode(value: unknown): value is TSESTree.Node { @@ -165,6 +242,97 @@ export const ExhaustiveDepsUtils = { ) }, + /** + * Checks whether the resolved variable is allowlisted by its type annotation + */ + variableIsAllowlistedByType(params: { + allowlistedTypes: Set + variable: TSESLint.Scope.Variable | null + }): boolean { + const { allowlistedTypes, variable } = params + if (allowlistedTypes.size === 0) return false + if (!variable) return false + + for (const id of variable.identifiers) { + if (id.typeAnnotation) { + const typeIdentifiers = new Set() + ExhaustiveDepsUtils.collectTypeIdentifiers( + id.typeAnnotation.typeAnnotation, + typeIdentifiers, + ) + for (const typeIdentifier of typeIdentifiers) { + if (allowlistedTypes.has(typeIdentifier)) return true + } + } + } + + return false + }, + isInstanceOfKind(node: TSESTree.Node) { + return ( + node.type === AST_NODE_TYPES.BinaryExpression && + node.operator === 'instanceof' + ) + }, + + /** + * Normalizes a chain by removing optional chaining operators + * + * Example: `a?.b.c!` -> `a.b.c` + */ + normalizeChain(text: string): string { + return text.replace(/(?:\?(\.)|!)/g, '$1') + }, + + /** + * Computes the reference path for an identifier + * + * Example: `a.b.c!` -> `{ path: 'a.b.c', root: 'a' }` + */ + computeRefPath(params: { + identifier: TSESTree.Identifier + sourceCode: Readonly + }): { path: string; root: string; coversRootMembers: boolean } | null { + const { identifier, sourceCode } = params + + const fullChainNode = ASTUtils.traverseUpOnly(identifier, [ + AST_NODE_TYPES.MemberExpression, + AST_NODE_TYPES.TSNonNullExpression, + AST_NODE_TYPES.Identifier, + ]) + + const fullText = ExhaustiveDepsUtils.normalizeChain( + sourceCode.getText(fullChainNode), + ) + + const parent = fullChainNode.parent + let dependencyPath = fullText + let coversRootMembers = fullText === identifier.name + + if ( + parent && + parent.type === AST_NODE_TYPES.CallExpression && + parent.callee === fullChainNode + ) { + const segments = fullText.split('.') + if (segments.length > 1) { + dependencyPath = segments.slice(0, -1).join('.') + } + + coversRootMembers = false + } + + dependencyPath = + dependencyPath.split('.')[0] === '' ? identifier.name : dependencyPath + const root = dependencyPath.split('.')[0] + + return { + path: dependencyPath, + root: root ?? identifier.name, + coversRootMembers: coversRootMembers && dependencyPath === root, + } + }, + collectExternalRefsInFunction(params: { functionNode: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression scopeManager: TSESLint.Scope.ScopeManager @@ -210,4 +378,61 @@ export const ExhaustiveDepsUtils = { return externalRefs }, + + /** + * Recursively collects type identifiers from a type annotation + */ + collectTypeIdentifiers(typeNode: TSESTree.TypeNode, out: Set): void { + switch (typeNode.type) { + case AST_NODE_TYPES.TSTypeReference: { + if (typeNode.typeName.type === AST_NODE_TYPES.Identifier) { + out.add(typeNode.typeName.name) + } + break + } + case AST_NODE_TYPES.TSUnionType: + case AST_NODE_TYPES.TSIntersectionType: { + typeNode.types.forEach((t) => + ExhaustiveDepsUtils.collectTypeIdentifiers(t, out), + ) + break + } + case AST_NODE_TYPES.TSArrayType: { + ExhaustiveDepsUtils.collectTypeIdentifiers(typeNode.elementType, out) + break + } + case AST_NODE_TYPES.TSTupleType: { + typeNode.elementTypes.forEach((et) => + ExhaustiveDepsUtils.collectTypeIdentifiers(et, out), + ) + break + } + } + }, + + /** + * Gets the function expression nodes from a queryFn property, handling conditional expressions. + * When neither branch is skipToken, returns both branches so all deps are scanned. + */ + getQueryFnNodes(queryFn: TSESTree.Property): Array { + if (queryFn.value.type !== AST_NODE_TYPES.ConditionalExpression) { + return [queryFn.value] + } + + if ( + queryFn.value.consequent.type === AST_NODE_TYPES.Identifier && + queryFn.value.consequent.name === 'skipToken' + ) { + return [queryFn.value.alternate] + } + + if ( + queryFn.value.alternate.type === AST_NODE_TYPES.Identifier && + queryFn.value.alternate.name === 'skipToken' + ) { + return [queryFn.value.consequent] + } + + return [queryFn.value.consequent, queryFn.value.alternate] + }, } diff --git a/packages/eslint-plugin-query/src/utils/ast-utils.ts b/packages/eslint-plugin-query/src/utils/ast-utils.ts index 28c9b6f7421..a44ae864549 100644 --- a/packages/eslint-plugin-query/src/utils/ast-utils.ts +++ b/packages/eslint-plugin-query/src/utils/ast-utils.ts @@ -42,10 +42,9 @@ export const ASTUtils = { properties: Array, key: string, ): TSESTree.Property | undefined { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - return properties.find((x) => + return properties.find((x): x is TSESTree.Property => ASTUtils.isPropertyWithIdentifierKey(x, key), - ) as TSESTree.Property | undefined + ) }, getNestedIdentifiers(node: TSESTree.Node): Array { const identifiers: Array = [] @@ -132,28 +131,6 @@ export const ASTUtils = { return identifiers }, - isAncestorIsCallee(identifier: TSESTree.Node) { - let previousNode = identifier - let currentNode = identifier.parent - - while (currentNode !== undefined) { - if ( - currentNode.type === AST_NODE_TYPES.CallExpression && - currentNode.callee === previousNode - ) { - return true - } - - if (currentNode.type !== AST_NODE_TYPES.MemberExpression) { - return false - } - - previousNode = currentNode - currentNode = currentNode.parent - } - - return false - }, traverseUpOnly( identifier: TSESTree.Node, allowedNodeTypes: Array, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4d849240c4..f206ccf7cc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -894,6 +894,28 @@ importers: specifier: ^6.4.1 version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2) + examples/react/eslint-plugin-demo: + dependencies: + '@tanstack/react-query': + specifier: ^5.91.0 + version: link:../../../packages/react-query + react: + specifier: ^19.0.0 + version: 19.2.4 + devDependencies: + '@tanstack/eslint-plugin-query': + specifier: ^5.91.5 + version: link:../../../packages/eslint-plugin-query + eslint: + specifier: ^9.39.0 + version: 9.39.4(jiti@2.6.1) + typescript: + specifier: 5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: 8.56.1 + version: 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + examples/react/infinite-query-with-max-pages: dependencies: '@tanstack/react-query': @@ -2351,13 +2373,13 @@ importers: version: 7.8.2 vite-plugin-dts: specifier: 4.2.3 - version: 4.2.3(@types/node@22.19.15)(rollup@4.57.1)(typescript@6.0.1-rc)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2)) + version: 4.2.3(@types/node@22.19.15)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2)) vite-plugin-externalize-deps: specifier: ^0.9.0 version: 0.9.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@6.0.1-rc)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2)) optionalDependencies: '@tanstack/query-devtools': specifier: workspace:* @@ -4764,7 +4786,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -20885,6 +20907,22 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -20912,7 +20950,6 @@ snapshots: typescript: 5.8.3 transitivePeerDependencies: - supports-color - optional: true '@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: @@ -20934,7 +20971,6 @@ snapshots: typescript: 5.8.3 transitivePeerDependencies: - supports-color - optional: true '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': dependencies: @@ -20967,12 +21003,23 @@ snapshots: '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.8.3)': dependencies: typescript: 5.8.3 - optional: true '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.8.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.1 @@ -21001,7 +21048,6 @@ snapshots: typescript: 5.8.3 transitivePeerDependencies: - supports-color - optional: true '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: @@ -21018,6 +21064,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.8.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -21429,19 +21486,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@vue/language-core@2.1.6(typescript@6.0.1-rc)': - dependencies: - '@volar/language-core': 2.4.28 - '@vue/compiler-dom': 3.5.28 - '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.28 - computeds: 0.0.1 - minimatch: 9.0.5 - muggle-string: 0.4.1 - path-browserify: 1.0.1 - optionalDependencies: - typescript: 6.0.1-rc - '@vue/language-core@2.2.12(typescript@5.8.3)': dependencies: '@volar/language-core': 2.4.15 @@ -30636,7 +30680,6 @@ snapshots: ts-api-utils@2.4.0(typescript@5.8.3): dependencies: typescript: 5.8.3 - optional: true ts-api-utils@2.4.0(typescript@5.9.3): dependencies: @@ -30661,10 +30704,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - tsconfck@3.1.6(typescript@6.0.1-rc): - optionalDependencies: - typescript: 6.0.1-rc - tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -30808,6 +30847,17 @@ snapshots: dependencies: semver: 7.7.4 + typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.8.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) @@ -31279,25 +31329,6 @@ snapshots: - rollup - supports-color - vite-plugin-dts@4.2.3(@types/node@22.19.15)(rollup@4.57.1)(typescript@6.0.1-rc)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2)): - dependencies: - '@microsoft/api-extractor': 7.47.7(@types/node@22.19.15) - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@volar/typescript': 2.4.28 - '@vue/language-core': 2.1.6(typescript@6.0.1-rc) - compare-versions: 6.1.1 - debug: 4.4.3 - kolorist: 1.8.0 - local-pkg: 0.5.1 - magic-string: 0.30.21 - typescript: 6.0.1-rc - optionalDependencies: - vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2) - transitivePeerDependencies: - - '@types/node' - - rollup - - supports-color - vite-plugin-externalize-deps@0.10.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2)): dependencies: vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2) @@ -31357,17 +31388,6 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@6.0.1-rc)(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2)): - dependencies: - debug: 4.4.3 - globrex: 0.1.2 - tsconfck: 3.1.6(typescript@6.0.1-rc) - optionalDependencies: - vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2) - transitivePeerDependencies: - - supports-color - - typescript - vite@6.4.1(@types/node@22.19.15)(jiti@1.21.7)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3