diff --git a/.changeset/three-weeks-taste.md b/.changeset/three-weeks-taste.md new file mode 100644 index 00000000..c732ca9d --- /dev/null +++ b/.changeset/three-weeks-taste.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Track field usage and warn when a field goes unused diff --git a/README.md b/README.md index 8bd807c8..6dc630b8 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co - `extraTypes` allows you to specify imports or declare types to help with `scalar` definitions - `shouldCheckForColocatedFragments` when turned on, this will scan your imports to find unused fragments and provide a message notifying you about them +- `trackFieldUsage` this only works with the client-preset, when turned on it will warn you about + unused fields within the same file. ### GraphQL Code Generator client-preset @@ -82,6 +84,7 @@ For folks using the `client-preset` you can ues the following config "schema": "./schema.graphql", "disableTypegen": true, "templateIsCallExpression": true, + "trackFieldUsage": true, "template": "graphql" } ] @@ -89,6 +92,28 @@ For folks using the `client-preset` you can ues the following config } ``` +## Tracking unused fields + +Currently the tracking unused fields feature has a few caveats with regards to tracking, first and foremost +it will only track in the same file to encourage [fragment co-location](https://www.apollographql.com/docs/react/data/fragments/#colocating-fragments). +Secondly it supports a few patterns which we'll add to as time progresses: + +```ts +// Supported cases: +const result = (await client.query()) || useFragment(); +const [result] = useQuery(); // --> urql +const { data } = useQuery(); // --> Apollo +// Missing cases: +const { field } = useFragment(); // can't destructure into your data from the start +const [{ data }] = useQuery(); // can't follow array destructuring with object destructuring +const { + data: { pokemon }, +} = useQuery(); // can't destructure into your data from the start +``` + +Lastly we don't track mutations/subscriptions as some folks will add additional fields to properly support +normalised cache updates. + ## Fragment masking When we use a `useQuery` that supports `TypedDocumentNode` it will automatically pick up the typings diff --git a/packages/example-external-generator/package.json b/packages/example-external-generator/package.json index 9644e45f..8bdf8531 100644 --- a/packages/example-external-generator/package.json +++ b/packages/example-external-generator/package.json @@ -12,12 +12,14 @@ "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "@urql/core": "^3.0.0", - "graphql": "^16.8.1" + "graphql": "^16.8.1", + "urql": "^4.0.6" }, "devDependencies": { "@0no-co/graphqlsp": "file:../graphqlsp", "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/client-preset": "^4.1.0", + "@types/react": "^18.2.45", "ts-node": "^10.9.1", "typescript": "^5.3.3" } diff --git a/packages/example-external-generator/src/Pokemon.tsx b/packages/example-external-generator/src/Pokemon.tsx index 57484e61..fe691254 100644 --- a/packages/example-external-generator/src/Pokemon.tsx +++ b/packages/example-external-generator/src/Pokemon.tsx @@ -14,12 +14,6 @@ export const PokemonFields = graphql(` } `) -export const WeakFields = graphql(` - fragment weaknessFields on Pokemon { - weaknesses - } -`) - export const Pokemon = (data: any) => { const pokemon = useFragment(PokemonFields, data); return `hi ${pokemon.name}`; diff --git a/packages/example-external-generator/src/gql/gql.ts b/packages/example-external-generator/src/gql/gql.ts index 0aac7628..edbe1b17 100644 --- a/packages/example-external-generator/src/gql/gql.ts +++ b/packages/example-external-generator/src/gql/gql.ts @@ -15,14 +15,8 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/ const documents = { '\n fragment pokemonFields on Pokemon {\n id\n name\n attacks {\n fast {\n damage\n name\n }\n }\n }\n': types.PokemonFieldsFragmentDoc, - '\n fragment weaknessFields on Pokemon {\n weaknesses\n }\n': - types.WeaknessFieldsFragmentDoc, - '\n query Pok($limit: Int!) {\n pokemons(limit: $limit) {\n id\n name\n fleeRate\n classification\n ...pokemonFields\n ...weaknessFields\n __typename\n }\n }\n': - types.PokDocument, - '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n __typename\n }\n }\n': + '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n': types.PoDocument, - '\n query PokemonsAreAwesome {\n pokemons {\n id\n }\n }\n': - types.PokemonsAreAwesomeDocument, }; /** @@ -49,26 +43,8 @@ export function graphql( * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: '\n fragment weaknessFields on Pokemon {\n weaknesses\n }\n' -): (typeof documents)['\n fragment weaknessFields on Pokemon {\n weaknesses\n }\n']; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql( - source: '\n query Pok($limit: Int!) {\n pokemons(limit: $limit) {\n id\n name\n fleeRate\n classification\n ...pokemonFields\n ...weaknessFields\n __typename\n }\n }\n' -): (typeof documents)['\n query Pok($limit: Int!) {\n pokemons(limit: $limit) {\n id\n name\n fleeRate\n classification\n ...pokemonFields\n ...weaknessFields\n __typename\n }\n }\n']; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql( - source: '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n __typename\n }\n }\n' -): (typeof documents)['\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n __typename\n }\n }\n']; -/** - * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. - */ -export function graphql( - source: '\n query PokemonsAreAwesome {\n pokemons {\n id\n }\n }\n' -): (typeof documents)['\n query PokemonsAreAwesome {\n pokemons {\n id\n }\n }\n']; + source: '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n' +): (typeof documents)['\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n']; export function graphql(source: string) { return (documents as any)[source] ?? {}; diff --git a/packages/example-external-generator/src/gql/graphql.ts b/packages/example-external-generator/src/gql/graphql.ts index 693f35b3..6d5427bf 100644 --- a/packages/example-external-generator/src/gql/graphql.ts +++ b/packages/example-external-generator/src/gql/graphql.ts @@ -131,52 +131,35 @@ export type PokemonFieldsFragment = { } | null; } & { ' $fragmentName'?: 'PokemonFieldsFragment' }; -export type WeaknessFieldsFragment = { - __typename?: 'Pokemon'; - weaknesses?: Array | null; -} & { ' $fragmentName'?: 'WeaknessFieldsFragment' }; - -export type PokQueryVariables = Exact<{ - limit: Scalars['Int']['input']; +export type PoQueryVariables = Exact<{ + id: Scalars['ID']['input']; }>; -export type PokQuery = { +export type PoQuery = { __typename?: 'Query'; - pokemons?: Array< + pokemon?: | ({ __typename: 'Pokemon'; id: string; - name: string; fleeRate?: number | null; - classification?: string | null; + name: string; + attacks?: { + __typename?: 'AttacksConnection'; + special?: Array<{ + __typename?: 'Attack'; + name?: string | null; + damage?: number | null; + } | null> | null; + } | null; + weight?: { + __typename?: 'PokemonDimension'; + minimum?: string | null; + maximum?: string | null; + } | null; } & { - ' $fragmentRefs'?: { - PokemonFieldsFragment: PokemonFieldsFragment; - WeaknessFieldsFragment: WeaknessFieldsFragment; - }; + ' $fragmentRefs'?: { PokemonFieldsFragment: PokemonFieldsFragment }; }) - | null - > | null; -}; - -export type PoQueryVariables = Exact<{ - id: Scalars['ID']['input']; -}>; - -export type PoQuery = { - __typename?: 'Query'; - pokemon?: { - __typename: 'Pokemon'; - id: string; - fleeRate?: number | null; - } | null; -}; - -export type PokemonsAreAwesomeQueryVariables = Exact<{ [key: string]: never }>; - -export type PokemonsAreAwesomeQuery = { - __typename?: 'Query'; - pokemons?: Array<{ __typename?: 'Pokemon'; id: string } | null> | null; + | null; }; export const PokemonFieldsFragmentDoc = { @@ -222,42 +205,20 @@ export const PokemonFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; -export const WeaknessFieldsFragmentDoc = { - kind: 'Document', - definitions: [ - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'weaknessFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'weaknesses' } }, - ], - }, - }, - ], -} as unknown as DocumentNode; -export const PokDocument = { +export const PoDocument = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'query', - name: { kind: 'Name', value: 'Pok' }, + name: { kind: 'Name', value: 'Po' }, variableDefinitions: [ { kind: 'VariableDefinition', - variable: { - kind: 'Variable', - name: { kind: 'Name', value: 'limit' }, - }, + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, type: { kind: 'NonNullType', - type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, }, }, ], @@ -266,14 +227,14 @@ export const PokDocument = { selections: [ { kind: 'Field', - name: { kind: 'Name', value: 'pokemons' }, + name: { kind: 'Name', value: 'pokemon' }, arguments: [ { kind: 'Argument', - name: { kind: 'Name', value: 'limit' }, + name: { kind: 'Name', value: 'id' }, value: { kind: 'Variable', - name: { kind: 'Name', value: 'limit' }, + name: { kind: 'Name', value: 'id' }, }, }, ], @@ -281,20 +242,55 @@ export const PokDocument = { kind: 'SelectionSet', selections: [ { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, - { - kind: 'Field', - name: { kind: 'Name', value: 'classification' }, - }, { kind: 'FragmentSpread', name: { kind: 'Name', value: 'pokemonFields' }, }, { - kind: 'FragmentSpread', - name: { kind: 'Name', value: 'weaknessFields' }, + kind: 'Field', + name: { kind: 'Name', value: 'attacks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'special' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'damage' }, + }, + ], + }, + }, + ], + }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'weight' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'minimum' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'maximum' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, ], }, @@ -340,94 +336,5 @@ export const PokDocument = { ], }, }, - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'weaknessFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'weaknesses' } }, - ], - }, - }, - ], -} as unknown as DocumentNode; -export const PoDocument = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: { kind: 'Name', value: 'Po' }, - variableDefinitions: [ - { - kind: 'VariableDefinition', - variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, - type: { - kind: 'NonNullType', - type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'pokemon' }, - arguments: [ - { - kind: 'Argument', - name: { kind: 'Name', value: 'id' }, - value: { - kind: 'Variable', - name: { kind: 'Name', value: 'id' }, - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, - { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, - ], - }, - }, - ], - }, - }, ], } as unknown as DocumentNode; -export const PokemonsAreAwesomeDocument = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: { kind: 'Name', value: 'PokemonsAreAwesome' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'pokemons' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode< - PokemonsAreAwesomeQuery, - PokemonsAreAwesomeQueryVariables ->; diff --git a/packages/example-external-generator/src/index.tsx b/packages/example-external-generator/src/index.tsx index f6d3f674..d05ffdcc 100644 --- a/packages/example-external-generator/src/index.tsx +++ b/packages/example-external-generator/src/index.tsx @@ -1,45 +1,51 @@ -import { createClient } from '@urql/core'; +import { createClient, useQuery } from 'urql'; import { graphql } from './gql'; - -const x = graphql(` - query Pok($limit: Int!) { - pokemons(limit: $limit) @populate { - id - name - fleeRate - classification - ...pokemonFields - ...weaknessFields - __typename - } - } -`) - -const client = createClient({ - url: '', -}); +import { Pokemon } from './Pokemon'; const PokemonQuery = graphql(` query Po($id: ID!) { pokemon(id: $id) { id fleeRate + ...pokemonFields + attacks { + special { + name + damage + } + } + weight { + minimum + maximum + } + name __typename } } `); -client - .query(PokemonQuery, { id: '' }) - .toPromise() - .then(result => { - result.data?.pokemon; +const Pokemons = () => { + const [result] = useQuery({ + query: PokemonQuery, + variables: { id: '' } }); + + // Works + console.log(result.data?.pokemon?.attacks && result.data?.pokemon?.attacks.special && result.data?.pokemon?.attacks.special[0] && result.data?.pokemon?.attacks.special[0].name) + + // Works + const { fleeRate } = result.data?.pokemon || {}; + console.log(fleeRate) + // Works + const po = result.data?.pokemon; + // @ts-expect-error + const { pokemon: { weight: { minimum } } } = result.data || {}; + console.log(po?.name, minimum) + + // Works + const { pokemon } = result.data || {}; + console.log(pokemon?.weight?.maximum) + + return ; +} -const myQuery = graphql(` - query PokemonsAreAwesome { - pokemons { - id - } - } -`); diff --git a/packages/example-external-generator/tsconfig.json b/packages/example-external-generator/tsconfig.json index 6124d121..430d62b9 100644 --- a/packages/example-external-generator/tsconfig.json +++ b/packages/example-external-generator/tsconfig.json @@ -7,9 +7,11 @@ "disableTypegen": true, "shouldCheckForColocatedFragments": false, "template": "graphql", - "templateIsCallExpression": true + "templateIsCallExpression": true, + "trackFieldUsage": true } ], + "jsx": "react-jsx", /* Language and Environment */ "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, /* Modules */ diff --git a/packages/example/src/index.generated.ts b/packages/example/src/index.generated.ts index 5b89fe48..ebe9bf0b 100644 --- a/packages/example/src/index.generated.ts +++ b/packages/example/src/index.generated.ts @@ -1,225 +1,18 @@ import * as Types from '../__generated__/baseGraphQLSP'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; -export type PokQueryVariables = Types.Exact<{ - limit: Types.Scalars['Int']['input']; +export type PoQueryVariables = Types.Exact<{ + id: Types.Scalars['ID']['input']; }>; -export type PokQuery = { +export type PoQuery = { __typename: 'Query'; - pokemons?: Array<{ + pokemon?: { __typename: 'Pokemon'; id: string; - name: string; fleeRate?: number | null; - classification?: string | null; - weaknesses?: Array | null; - attacks?: { - __typename: 'AttacksConnection'; - fast?: Array<{ - __typename: 'Attack'; - damage?: number | null; - name?: string | null; - } | null> | null; - } | null; - } | null> | null; -}; - -export type PokemonFieldsFragment = { - __typename: 'Pokemon'; - id: string; - name: string; - attacks?: { - __typename: 'AttacksConnection'; - fast?: Array<{ - __typename: 'Attack'; - damage?: number | null; - name?: string | null; - } | null> | null; } | null; }; -export type WeaknessFieldsFragment = { - __typename: 'Pokemon'; - weaknesses?: Array | null; -}; - -export type PoQueryVariables = Types.Exact<{ - id: Types.Scalars['ID']['input']; -}>; - -export const PokemonFieldsFragmentDoc = { - kind: 'Document', - definitions: [ - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'pokemonFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - { - kind: 'Field', - name: { kind: 'Name', value: 'attacks' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'fast' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'damage' }, - }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode; -export const WeaknessFieldsFragmentDoc = { - kind: 'Document', - definitions: [ - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'weaknessFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'weaknesses' } }, - ], - }, - }, - ], -} as unknown as DocumentNode; -export const PokDocument = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: { kind: 'Name', value: 'Pok' }, - variableDefinitions: [ - { - kind: 'VariableDefinition', - variable: { - kind: 'Variable', - name: { kind: 'Name', value: 'limit' }, - }, - type: { - kind: 'NonNullType', - type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'pokemons' }, - arguments: [ - { - kind: 'Argument', - name: { kind: 'Name', value: 'limit' }, - value: { - kind: 'Variable', - name: { kind: 'Name', value: 'limit' }, - }, - }, - ], - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, - { - kind: 'FragmentSpread', - name: { kind: 'Name', value: 'pokemonFields' }, - }, - { - kind: 'FragmentSpread', - name: { kind: 'Name', value: 'weaknessFields' }, - }, - { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, - ], - }, - }, - ], - }, - }, - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'pokemonFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - { - kind: 'Field', - name: { kind: 'Name', value: 'attacks' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'fast' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'damage' }, - }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'weaknessFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'Pokemon' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'weaknesses' } }, - ], - }, - }, - ], -} as unknown as DocumentNode; export const PoDocument = { kind: 'Document', definitions: [ @@ -266,4 +59,4 @@ export const PoDocument = { }, }, ], -} as unknown as DocumentNode; +} as unknown as DocumentNode; diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 07a91d81..4316d24a 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,27 +1,6 @@ import { gql, createClient } from '@urql/core'; import { Pokemon, PokemonFields, WeakFields } from './Pokemon'; -const x = gql` - query Pok($limit: Int!) { - pokemons(limit: $limit) @populate { - id - name - fleeRate - classification - ...pokemonFields - ...weaknessFields - __typename - } - } - - ${PokemonFields} - ${WeakFields} -` as typeof import('./index.generated').PokDocument; - -const client = createClient({ - url: '', -}); - const PokemonQuery = gql` query Po($id: ID!) { pokemon(id: $id) { @@ -38,11 +17,3 @@ client .then(result => { result.data?.pokemon; }); - -const myQuery = gql` - query PokemonsAreAwesome { - pokemons { - id - } - } -`; diff --git a/packages/graphqlsp/README.md b/packages/graphqlsp/README.md index ab9a98c4..2378ced8 100644 --- a/packages/graphqlsp/README.md +++ b/packages/graphqlsp/README.md @@ -60,6 +60,8 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co - `extraTypes` allows you to specify imports or declare types to help with `scalar` definitions - `shouldCheckForColocatedFragments` when turned on, this will scan your imports to find unused fragments and provide a message notifying you about them +- `trackFieldUsage` this only works with the client-preset, when turned on it will warn you about + unused fields within the same file. ### GraphQL Code Generator client-preset @@ -74,6 +76,7 @@ For folks using the `client-preset` you can ues the following config "schema": "./schema.graphql", "disableTypegen": true, "templateIsCallExpression": true, + "trackFieldUsage": true, "template": "graphql" } ] @@ -81,6 +84,28 @@ For folks using the `client-preset` you can ues the following config } ``` +## Tracking unused fields + +Currently the tracking unused fields feature has a few caveats with regards to tracking, first and foremost +it will only track in the same file to encourage [fragment co-location](https://www.apollographql.com/docs/react/data/fragments/#colocating-fragments). +Secondly it supports a few patterns which we'll add to as time progresses: + +```ts +// Supported cases: +const result = (await client.query()) || useFragment(); +const [result] = useQuery(); // --> urql +const { data } = useQuery(); // --> Apollo +// Missing cases: +const { field } = useFragment(); // can't destructure into your data from the start +const [{ data }] = useQuery(); // can't follow array destructuring with object destructuring +const { + data: { pokemon }, +} = useQuery(); // can't destructure into your data from the start +``` + +Lastly we don't track mutations/subscriptions as some folks will add additional fields to properly support +normalised cache updates. + ## Fragment masking When we use a `useQuery` that supports `TypedDocumentNode` it will automatically pick up the typings diff --git a/packages/graphqlsp/src/diagnostics.ts b/packages/graphqlsp/src/diagnostics.ts index e06d28b9..366d699e 100644 --- a/packages/graphqlsp/src/diagnostics.ts +++ b/packages/graphqlsp/src/diagnostics.ts @@ -7,6 +7,7 @@ import { OperationDefinitionNode, parse, print, + visit, } from 'graphql'; import { LRUCache } from 'lru-cache'; import fnv1a from '@sindresorhus/fnv1a'; @@ -15,11 +16,13 @@ import { findAllCallExpressions, findAllImports, findAllTaggedTemplateNodes, + findNode, getSource, isFileDirty, } from './ast'; import { resolveTemplate } from './ast/resolve'; import { generateTypedDocumentNodes } from './graphql/generateTypes'; +import { Logger } from '.'; const clientDirectives = new Set([ 'populate', @@ -40,6 +43,7 @@ export const SEMANTIC_DIAGNOSTIC_CODE = 52001; export const MISSING_OPERATION_NAME_CODE = 52002; export const MISSING_FRAGMENT_CODE = 52003; export const USING_DEPRECATED_FIELD_CODE = 52004; +export const UNUSED_FIELD_CODE = 52005; let isGeneratingTypes = false; @@ -93,7 +97,7 @@ export function getGraphQLDiagnostics( let tsDiagnostics: ts.Diagnostic[] = []; const cacheKey = fnv1a( isCallExpression - ? texts.join('-') + + ? source.getText() + fragments.map(x => print(x)).join('-') + schema.version : texts.join('-') + schema.version @@ -304,11 +308,307 @@ const runDiagnostics = ( messageText: diag.message.split('\n')[0], })); - const importDiagnostics = checkImportsForFragments(source, info); + const importDiagnostics = isCallExpression + ? checkFieldUsageInFile( + source, + nodes as ts.NoSubstitutionTemplateLiteral[], + info + ) + : checkImportsForFragments(source, info); return [...tsDiagnostics, ...importDiagnostics]; }; +const getVariableDeclaration = (start: ts.NoSubstitutionTemplateLiteral) => { + let node: any = start; + let counter = 0; + while (!ts.isVariableDeclaration(node) && node.parent && counter < 5) { + node = node.parent; + counter++; + } + return node; +}; + +const traverseDestructuring = ( + node: ts.ObjectBindingPattern, + originalWip: Array, + allFields: Array, + source: ts.SourceFile, + info: ts.server.PluginCreateInfo +): Array => { + const results = []; + for (const binding of node.elements) { + if (ts.isObjectBindingPattern(binding.name)) { + const wip = [...originalWip]; + if ( + binding.propertyName && + allFields.includes(binding.propertyName.getText()) && + !originalWip.includes(binding.propertyName.getText()) + ) { + wip.push(binding.propertyName.getText()); + } + const traverseResult = traverseDestructuring( + binding.name, + wip, + allFields, + source, + info + ); + + results.push(...traverseResult); + } else if (ts.isIdentifier(binding.name)) { + const wip = [...originalWip]; + if ( + binding.propertyName && + allFields.includes(binding.propertyName.getText()) && + !originalWip.includes(binding.propertyName.getText()) + ) { + wip.push(binding.propertyName.getText()); + } else { + wip.push(binding.name.getText()); + } + + const crawlResult = crawlScope( + binding.name, + wip, + allFields, + source, + info + ); + + results.push(...crawlResult); + } + } + + return results; +}; + +const crawlScope = ( + node: ts.Identifier | ts.BindingName, + originalWip: Array, + allFields: Array, + source: ts.SourceFile, + info: ts.server.PluginCreateInfo +): Array => { + let results: string[] = []; + + const references = info.languageService.getReferencesAtPosition( + source.fileName, + node.getStart() + ); + + if (!references) return results; + + // Go over all the references tied to the result of + // accessing our equery and collect them as fully + // qualified paths (ideally ending in a leaf-node) + results = references.flatMap(ref => { + // If we get a reference to a different file we can bail + if (ref.fileName !== source.fileName) return []; + // We don't want to end back at our document so we narrow + // the scope. + if ( + node.getStart() <= ref.textSpan.start && + node.getEnd() >= ref.textSpan.start + ref.textSpan.length + ) + return []; + + let foundRef = findNode(source, ref.textSpan.start); + if (!foundRef) return []; + + const pathParts = [...originalWip]; + // In here we'll start crawling all the accessors of result + // and try to determine the total path + // - result.data.pokemon.name --> pokemon.name this is the easy route and never accesses + // any of the recursive functions + // - const pokemon = result.data.pokemon --> this initiates a new crawl with a renewed scope + // - const { pokemon } = result.data --> this initiates a destructuring traversal which will + // either end up in more destructuring traversals or a scope crawl + while ( + ts.isIdentifier(foundRef) || + ts.isPropertyAccessExpression(foundRef) || + ts.isElementAccessExpression(foundRef) || + ts.isVariableDeclaration(foundRef) || + ts.isBinaryExpression(foundRef) + ) { + if (ts.isVariableDeclaration(foundRef)) { + if (ts.isIdentifier(foundRef.name)) { + // We have already added the paths because of the right-hand expression, + // const pokemon = result.data.pokemon --> we have pokemon as our path, + // now re-crawling pokemon for all of its accessors should deliver us the usage + // patterns... This might get expensive though if we need to perform this deeply. + return crawlScope(foundRef.name, pathParts, allFields, source, info); + } else if (ts.isObjectBindingPattern(foundRef.name)) { + // First we need to traverse the left-hand side of the variable assignment, + // this could be tree-like as we could be dealing with + // - const { x: { y: z }, a: { b: { c, d }, e: { f } } } = result.data + // Which we will need several paths for... + // after doing that we need to re-crawl all of the resulting variables + // Crawl down until we have either a leaf node or an object/array that can + // be recrawled + return traverseDestructuring( + foundRef.name, + pathParts, + allFields, + source, + info + ); + } + } else if ( + ts.isIdentifier(foundRef) && + allFields.includes(foundRef.text) && + !pathParts.includes(foundRef.text) + ) { + pathParts.push(foundRef.text); + } else if ( + ts.isPropertyAccessExpression(foundRef) && + allFields.includes(foundRef.name.text) && + !pathParts.includes(foundRef.name.text) + ) { + pathParts.push(foundRef.name.text); + } else if ( + ts.isElementAccessExpression(foundRef) && + ts.isStringLiteral(foundRef.argumentExpression) && + allFields.includes(foundRef.argumentExpression.text) && + !pathParts.includes(foundRef.argumentExpression.text) + ) { + pathParts.push(foundRef.argumentExpression.text); + } + + foundRef = foundRef.parent; + } + + return pathParts.join('.'); + }); + + return results; +}; + +const checkFieldUsageInFile = ( + source: ts.SourceFile, + nodes: ts.NoSubstitutionTemplateLiteral[], + info: ts.server.PluginCreateInfo +) => { + const logger: Logger = (msg: string) => + info.project.projectService.logger.info(`[GraphQLSP] ${msg}`); + const diagnostics: ts.Diagnostic[] = []; + const shouldTrackFieldUsage = info.config.trackFieldUsage ?? false; + if (!shouldTrackFieldUsage) return diagnostics; + + nodes.forEach(node => { + const nodeText = node.getText(); + // Bailing for mutations/subscriptions as these could have small details + // for normalised cache interactions + if (nodeText.includes('mutation') || nodeText.includes('subscription')) + return; + + const variableDeclaration = getVariableDeclaration(node); + if (!ts.isVariableDeclaration(variableDeclaration)) return; + + const references = info.languageService.getReferencesAtPosition( + source.fileName, + variableDeclaration.name.getStart() + ); + if (!references) return; + + references.forEach(ref => { + if (ref.fileName !== source.fileName) return; + + let found = findNode(source, ref.textSpan.start); + while (found && !ts.isVariableStatement(found)) { + found = found.parent; + } + + if (!found || !ts.isVariableStatement(found)) return; + + const [output] = found.declarationList.declarations; + + if (output.name.getText() === variableDeclaration.name.getText()) return; + + const inProgress: string[] = []; + const allPaths: string[] = []; + const allFields: string[] = []; + const reserved = ['id', '__typename']; + const fieldToLoc = new Map(); + // This visitor gets all the leaf-paths in the document + // as well as all fields that are part of the document + // We need the leaf-paths to check usage and we need the + // fields to validate whether an access on a given reference + // is valid given the current document... + visit(parse(node.getText().slice(1, -1)), { + Field: { + enter: node => { + if (!reserved.includes(node.name.value)) { + allFields.push(node.name.value); + } + + if (!node.selectionSet && !reserved.includes(node.name.value)) { + let p; + if (inProgress.length) { + p = inProgress.join('.') + '.' + node.name.value; + } else { + p = node.name.value; + } + allPaths.push(p); + + fieldToLoc.set(p, { + start: node.name.loc!.start, + length: node.name.loc!.end - node.name.loc!.start, + }); + } else if (node.selectionSet) { + inProgress.push(node.name.value); + } + }, + leave: node => { + if (node.selectionSet) { + inProgress.pop(); + } + }, + }, + }); + + let temp = output.name; + // Supported cases: + // - const result = await client.query() || useFragment() + // - const [result] = useQuery() --> urql + // - const { data } = useQuery() --> Apollo + // - const { field } = useFragment() + // - const [{ data }] = useQuery() + // - const { data: { pokemon } } = useQuery() + if ( + ts.isArrayBindingPattern(temp) && + ts.isBindingElement(temp.elements[0]) + ) { + temp = temp.elements[0].name; + } + + let allAccess: string[] = []; + if (ts.isObjectBindingPattern(temp)) { + allAccess = traverseDestructuring(temp, [], allFields, source, info); + } else { + allAccess = crawlScope(temp, [], allFields, source, info); + } + + const unused = allPaths.filter(x => !allAccess.includes(x)); + unused.forEach(unusedField => { + const loc = fieldToLoc.get(unusedField); + if (!loc) return; + + diagnostics.push({ + file: source, + length: loc.length, + start: node.getStart() + loc.start + 1, + category: ts.DiagnosticCategory.Warning, + code: UNUSED_FIELD_CODE, + messageText: `Field '${unusedField}' is not used.`, + }); + }); + }); + }); + + return diagnostics; +}; + const checkImportsForFragments = ( source: ts.SourceFile, info: ts.server.PluginCreateInfo diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb9a5270..17fbc31e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: graphql: specifier: ^16.8.1 version: 16.8.1 + urql: + specifier: ^4.0.6 + version: 4.0.6(graphql@16.8.1)(react@18.2.0) devDependencies: '@0no-co/graphqlsp': specifier: file:../graphqlsp @@ -94,6 +97,9 @@ importers: '@graphql-codegen/client-preset': specifier: ^4.1.0 version: 4.1.0(graphql@16.8.1) + '@types/react': + specifier: ^18.2.45 + version: 18.2.45 ts-node: specifier: ^10.9.1 version: 10.9.1(@types/node@18.15.11)(typescript@5.3.3) @@ -179,6 +185,31 @@ importers: specifier: ^5.3.3 version: 5.3.3 + test/e2e/fixture-project-unused-fields: + dependencies: + '@0no-co/graphqlsp': + specifier: workspace:* + version: link:../../../packages/graphqlsp + '@graphql-typed-document-node/core': + specifier: ^3.0.0 + version: 3.2.0(graphql@16.8.1) + '@urql/core': + specifier: ^4.0.4 + version: 4.2.2(graphql@16.8.1) + graphql: + specifier: ^16.0.0 + version: 16.8.1 + urql: + specifier: ^4.0.4 + version: 4.0.6(graphql@16.8.1)(react@18.2.0) + devDependencies: + '@types/react': + specifier: 18.2.45 + version: 18.2.45 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + packages: /@0no-co/graphql.web@1.0.0(graphql@16.8.1): @@ -192,6 +223,17 @@ packages: graphql: 16.8.1 dev: false + /@0no-co/graphql.web@1.0.4(graphql@16.8.1): + resolution: {integrity: sha512-W3ezhHGfO0MS1PtGloaTpg0PbaT8aZSmmaerL7idtU5F7oCI+uu25k+MsMS31BVFlp4aMkHSrNRxiD72IlK8TA==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + dependencies: + graphql: 16.8.1 + dev: false + /@ampproject/remapping@2.2.0: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -2283,6 +2325,22 @@ packages: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true + /@types/prop-types@15.7.11: + resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} + dev: true + + /@types/react@18.2.45: + resolution: {integrity: sha512-TtAxCNrlrBp8GoeEp1npd5g+d/OejJHFxS3OWmrPBMFaVQMSN0OFySozJio5BHxTuTeug00AVXVAjfDSfk+lUg==} + dependencies: + '@types/prop-types': 15.7.11 + '@types/scheduler': 0.16.8 + csstype: 3.1.3 + dev: true + + /@types/scheduler@0.16.8: + resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==} + dev: true + /@types/semver@7.5.4: resolution: {integrity: sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==} dev: true @@ -2311,6 +2369,15 @@ packages: - graphql dev: false + /@urql/core@4.2.2(graphql@16.8.1): + resolution: {integrity: sha512-TP1kheq9bnrEdnVbJqh0g0ZY/wfdpPeAzjiiDK+Tm+Pbi0O1Xdu6+fUJ/wJo5QpHZzkIyya4/AecG63e6scFqQ==} + dependencies: + '@0no-co/graphql.web': 1.0.4(graphql@16.8.1) + wonka: 6.3.4 + transitivePeerDependencies: + - graphql + dev: false + /@vitest/expect@0.34.6: resolution: {integrity: sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==} dependencies: @@ -2991,6 +3058,10 @@ packages: which: 2.0.2 dev: true + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + dev: true + /csv-generate@3.4.3: resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} dev: true @@ -3431,14 +3502,6 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true - dev: true - optional: true - /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4804,6 +4867,13 @@ packages: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + /read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} @@ -4939,7 +5009,7 @@ packages: engines: {node: '>=14.18.0', npm: '>=8.0.0'} hasBin: true optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 dev: true /rollup@4.7.0: @@ -5572,6 +5642,18 @@ packages: resolution: {integrity: sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g==} dev: true + /urql@4.0.6(graphql@16.8.1)(react@18.2.0): + resolution: {integrity: sha512-meXJ2puOd64uCGKh7Fse2R7gPa8+ZpBOoA62jN7CPXXUt7SVZSdeXWSpB3HvlfzLUkEqsWbvshwrgeWRYNNGaQ==} + peerDependencies: + react: '>= 16.8.0' + dependencies: + '@urql/core': 4.2.2(graphql@16.8.1) + react: 18.2.0 + wonka: 6.3.4 + transitivePeerDependencies: + - graphql + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true @@ -5808,6 +5890,10 @@ packages: resolution: {integrity: sha512-nJyGPcjuBiaLFn8QAlrHd+QjV9AlPO7snOWAhgx6aX0nQLMV6Wi0nqfrkmsXIH0efngbDOroOz2QyLnZMF16Hw==} dev: false + /wonka@6.3.4: + resolution: {integrity: sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==} + dev: false + /wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} diff --git a/test/e2e/fixture-project-unused-fields/.vscode/settings.json b/test/e2e/fixture-project-unused-fields/.vscode/settings.json new file mode 100644 index 00000000..25fa6215 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/test/e2e/fixture-project-unused-fields/__generated__/baseGraphQLSP.ts b/test/e2e/fixture-project-unused-fields/__generated__/baseGraphQLSP.ts new file mode 100644 index 00000000..0c6465f0 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/__generated__/baseGraphQLSP.ts @@ -0,0 +1,115 @@ +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { + [K in keyof T]: T[K]; +}; +export type MakeOptional = Omit & { + [SubKey in K]?: Maybe; +}; +export type MakeMaybe = Omit & { + [SubKey in K]: Maybe; +}; +export type MakeEmpty< + T extends { [key: string]: unknown }, + K extends keyof T +> = { [_ in K]?: never }; +export type Incremental = + | T + | { + [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; + }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string }; + String: { input: string; output: string }; + Boolean: { input: boolean; output: boolean }; + Int: { input: number; output: number }; + Float: { input: number; output: number }; +}; + +/** Move a Pokémon can perform with the associated damage and type. */ +export type Attack = { + __typename: 'Attack'; + damage?: Maybe; + name?: Maybe; + type?: Maybe; +}; + +export type AttacksConnection = { + __typename: 'AttacksConnection'; + fast?: Maybe>>; + special?: Maybe>>; +}; + +/** Requirement that prevents an evolution through regular means of levelling up. */ +export type EvolutionRequirement = { + __typename: 'EvolutionRequirement'; + amount?: Maybe; + name?: Maybe; +}; + +export type Pokemon = { + __typename: 'Pokemon'; + attacks?: Maybe; + /** @deprecated And this is the reason why */ + classification?: Maybe; + evolutionRequirements?: Maybe>>; + evolutions?: Maybe>>; + /** Likelihood of an attempt to catch a Pokémon to fail. */ + fleeRate?: Maybe; + height?: Maybe; + id: Scalars['ID']['output']; + /** Maximum combat power a Pokémon may achieve at max level. */ + maxCP?: Maybe; + /** Maximum health points a Pokémon may achieve at max level. */ + maxHP?: Maybe; + name: Scalars['String']['output']; + resistant?: Maybe>>; + types?: Maybe>>; + weaknesses?: Maybe>>; + weight?: Maybe; +}; + +export type PokemonDimension = { + __typename: 'PokemonDimension'; + maximum?: Maybe; + minimum?: Maybe; +}; + +/** Elemental property associated with either a Pokémon or one of their moves. */ +export type PokemonType = + | 'Bug' + | 'Dark' + | 'Dragon' + | 'Electric' + | 'Fairy' + | 'Fighting' + | 'Fire' + | 'Flying' + | 'Ghost' + | 'Grass' + | 'Ground' + | 'Ice' + | 'Normal' + | 'Poison' + | 'Psychic' + | 'Rock' + | 'Steel' + | 'Water'; + +export type Query = { + __typename: 'Query'; + /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ + pokemon?: Maybe; + /** List out all Pokémon, optionally in pages */ + pokemons?: Maybe>>; +}; + +export type QueryPokemonArgs = { + id: Scalars['ID']['input']; +}; + +export type QueryPokemonsArgs = { + limit?: InputMaybe; + skip?: InputMaybe; +}; diff --git a/test/e2e/fixture-project-unused-fields/fixtures/destructuring.tsx b/test/e2e/fixture-project-unused-fields/fixtures/destructuring.tsx new file mode 100644 index 00000000..3f79626d --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/destructuring.tsx @@ -0,0 +1,49 @@ +import { useQuery } from 'urql'; +import { graphql } from './gql'; +// @ts-expect-error +import { Pokemon } from './fragment'; +import * as React from 'react'; + +const PokemonQuery = graphql(` + query Po($id: ID!) { + pokemon(id: $id) { + id + fleeRate + ...pokemonFields + attacks { + special { + name + damage + } + } + weight { + minimum + maximum + } + name + __typename + } + } +`); + +const Pokemons = () => { + const [result] = useQuery({ + query: PokemonQuery, + variables: { id: '' } + }); + + // Works + const { fleeRate } = result.data?.pokemon || {}; + console.log(fleeRate) + // @ts-expect-error + const { pokemon: { weight: { minimum } } } = result.data || {}; + console.log(minimum) + + // Works + const { pokemon } = result.data || {}; + console.log(pokemon?.weight?.maximum) + + // @ts-expect-error + return ; +} + diff --git a/test/e2e/fixture-project-unused-fields/fixtures/fragment-destructuring.tsx b/test/e2e/fixture-project-unused-fields/fixtures/fragment-destructuring.tsx new file mode 100644 index 00000000..203c4921 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/fragment-destructuring.tsx @@ -0,0 +1,27 @@ +import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { graphql } from './gql'; + +export const PokemonFields = graphql(` + fragment pokemonFields on Pokemon { + id + name + attacks { + fast { + damage + name + } + } + } +`) + +export const Pokemon = (data: any) => { + const { name } = useFragment(PokemonFields, data); + return `hi ${name}`; +}; + +export function useFragment( + _fragment: TypedDocumentNode, + data: any +): Type { + return data; +} diff --git a/test/e2e/fixture-project-unused-fields/fixtures/fragment.tsx b/test/e2e/fixture-project-unused-fields/fixtures/fragment.tsx new file mode 100644 index 00000000..c2dd7273 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/fragment.tsx @@ -0,0 +1,27 @@ +import { TypedDocumentNode } from '@graphql-typed-document-node/core'; +import { graphql } from './gql'; + +export const PokemonFields = graphql(` + fragment pokemonFields on Pokemon { + id + name + attacks { + fast { + damage + name + } + } + } +`) + +export const Pokemon = (data: any) => { + const pokemon = useFragment(PokemonFields, data); + return `hi ${pokemon.name}`; +}; + +export function useFragment( + _fragment: TypedDocumentNode, + data: any +): Type { + return data; +} diff --git a/test/e2e/fixture-project-unused-fields/fixtures/gql/fragment-masking.ts b/test/e2e/fixture-project-unused-fields/fixtures/gql/fragment-masking.ts new file mode 100644 index 00000000..a6a291c0 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/gql/fragment-masking.ts @@ -0,0 +1,85 @@ +import { + ResultOf, + DocumentTypeDecoration, + TypedDocumentNode, +} from '@graphql-typed-document-node/core'; +import { FragmentDefinitionNode } from 'graphql'; +import { Incremental } from './graphql'; + +export type FragmentType< + TDocumentType extends DocumentTypeDecoration +> = TDocumentType extends DocumentTypeDecoration + ? [TType] extends [{ ' $fragmentName'?: infer TKey }] + ? TKey extends string + ? { ' $fragmentRefs'?: { [key in TKey]: TType } } + : never + : never + : never; + +// return non-nullable if `fragmentType` is non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> +): TType; +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | null + | undefined +): TType | null | undefined; +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> +): ReadonlyArray; +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | ReadonlyArray>> + | null + | undefined +): ReadonlyArray | null | undefined; +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | ReadonlyArray>> + | null + | undefined +): TType | ReadonlyArray | null | undefined { + return fragmentType as any; +} + +export function makeFragmentData< + F extends DocumentTypeDecoration, + FT extends ResultOf +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType; +} +export function isFragmentReady( + queryNode: DocumentTypeDecoration, + fragmentNode: TypedDocumentNode, + data: + | FragmentType, any>> + | null + | undefined +): data is FragmentType { + const deferredFields = ( + queryNode as { + __meta__?: { deferredFields: Record }; + } + ).__meta__?.deferredFields; + + if (!deferredFields) return true; + + const fragDef = fragmentNode.definitions[0] as + | FragmentDefinitionNode + | undefined; + const fragName = fragDef?.name?.value; + + const fields = (fragName && deferredFields[fragName]) || []; + return fields.length > 0 && fields.every(field => data && field in data); +} diff --git a/test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts b/test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts new file mode 100644 index 00000000..edbe1b17 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/gql/gql.ts @@ -0,0 +1,54 @@ +/* eslint-disable */ +import * as types from './graphql'; +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + */ +const documents = { + '\n fragment pokemonFields on Pokemon {\n id\n name\n attacks {\n fast {\n damage\n name\n }\n }\n }\n': + types.PokemonFieldsFragmentDoc, + '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n': + types.PoDocument, +}; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. + */ +export function graphql(source: string): unknown; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n fragment pokemonFields on Pokemon {\n id\n name\n attacks {\n fast {\n damage\n name\n }\n }\n }\n' +): (typeof documents)['\n fragment pokemonFields on Pokemon {\n id\n name\n attacks {\n fast {\n damage\n name\n }\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n' +): (typeof documents)['\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n']; + +export function graphql(source: string) { + return (documents as any)[source] ?? {}; +} + +export type DocumentType> = + TDocumentNode extends DocumentNode ? TType : never; diff --git a/test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts b/test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts new file mode 100644 index 00000000..6d5427bf --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/gql/graphql.ts @@ -0,0 +1,340 @@ +/* eslint-disable */ +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { + [K in keyof T]: T[K]; +}; +export type MakeOptional = Omit & { + [SubKey in K]?: Maybe; +}; +export type MakeMaybe = Omit & { + [SubKey in K]: Maybe; +}; +export type MakeEmpty< + T extends { [key: string]: unknown }, + K extends keyof T +> = { [_ in K]?: never }; +export type Incremental = + | T + | { + [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; + }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string }; + String: { input: string; output: string }; + Boolean: { input: boolean; output: boolean }; + Int: { input: number; output: number }; + Float: { input: number; output: number }; +}; + +/** Move a Pokémon can perform with the associated damage and type. */ +export type Attack = { + __typename?: 'Attack'; + damage?: Maybe; + name?: Maybe; + type?: Maybe; +}; + +export type AttacksConnection = { + __typename?: 'AttacksConnection'; + fast?: Maybe>>; + special?: Maybe>>; +}; + +/** Requirement that prevents an evolution through regular means of levelling up. */ +export type EvolutionRequirement = { + __typename?: 'EvolutionRequirement'; + amount?: Maybe; + name?: Maybe; +}; + +export type Pokemon = { + __typename?: 'Pokemon'; + attacks?: Maybe; + /** @deprecated And this is the reason why */ + classification?: Maybe; + evolutionRequirements?: Maybe>>; + evolutions?: Maybe>>; + /** Likelihood of an attempt to catch a Pokémon to fail. */ + fleeRate?: Maybe; + height?: Maybe; + id: Scalars['ID']['output']; + /** Maximum combat power a Pokémon may achieve at max level. */ + maxCP?: Maybe; + /** Maximum health points a Pokémon may achieve at max level. */ + maxHP?: Maybe; + name: Scalars['String']['output']; + resistant?: Maybe>>; + types?: Maybe>>; + weaknesses?: Maybe>>; + weight?: Maybe; +}; + +export type PokemonDimension = { + __typename?: 'PokemonDimension'; + maximum?: Maybe; + minimum?: Maybe; +}; + +/** Elemental property associated with either a Pokémon or one of their moves. */ +export enum PokemonType { + Bug = 'Bug', + Dark = 'Dark', + Dragon = 'Dragon', + Electric = 'Electric', + Fairy = 'Fairy', + Fighting = 'Fighting', + Fire = 'Fire', + Flying = 'Flying', + Ghost = 'Ghost', + Grass = 'Grass', + Ground = 'Ground', + Ice = 'Ice', + Normal = 'Normal', + Poison = 'Poison', + Psychic = 'Psychic', + Rock = 'Rock', + Steel = 'Steel', + Water = 'Water', +} + +export type Query = { + __typename?: 'Query'; + /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ + pokemon?: Maybe; + /** List out all Pokémon, optionally in pages */ + pokemons?: Maybe>>; +}; + +export type QueryPokemonArgs = { + id: Scalars['ID']['input']; +}; + +export type QueryPokemonsArgs = { + limit?: InputMaybe; + skip?: InputMaybe; +}; + +export type PokemonFieldsFragment = { + __typename?: 'Pokemon'; + id: string; + name: string; + attacks?: { + __typename?: 'AttacksConnection'; + fast?: Array<{ + __typename?: 'Attack'; + damage?: number | null; + name?: string | null; + } | null> | null; + } | null; +} & { ' $fragmentName'?: 'PokemonFieldsFragment' }; + +export type PoQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + +export type PoQuery = { + __typename?: 'Query'; + pokemon?: + | ({ + __typename: 'Pokemon'; + id: string; + fleeRate?: number | null; + name: string; + attacks?: { + __typename?: 'AttacksConnection'; + special?: Array<{ + __typename?: 'Attack'; + name?: string | null; + damage?: number | null; + } | null> | null; + } | null; + weight?: { + __typename?: 'PokemonDimension'; + minimum?: string | null; + maximum?: string | null; + } | null; + } & { + ' $fragmentRefs'?: { PokemonFieldsFragment: PokemonFieldsFragment }; + }) + | null; +}; + +export const PokemonFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'pokemonFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Pokemon' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'attacks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'fast' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'damage' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const PoDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Po' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'pokemon' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'id' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'id' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'pokemonFields' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'attacks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'special' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'damage' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'weight' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'minimum' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'maximum' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'pokemonFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Pokemon' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'attacks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'fast' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'damage' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; diff --git a/test/e2e/fixture-project-unused-fields/fixtures/gql/index.ts b/test/e2e/fixture-project-unused-fields/fixtures/gql/index.ts new file mode 100644 index 00000000..c682b1e2 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/gql/index.ts @@ -0,0 +1,2 @@ +export * from './fragment-masking'; +export * from './gql'; diff --git a/test/e2e/fixture-project-unused-fields/fixtures/immediate-destructuring.tsx b/test/e2e/fixture-project-unused-fields/fixtures/immediate-destructuring.tsx new file mode 100644 index 00000000..aee6eea6 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/immediate-destructuring.tsx @@ -0,0 +1,39 @@ +import { useQuery } from 'urql'; +import { graphql } from './gql'; +// @ts-expect-error +import { Pokemon } from './fragment'; +import * as React from 'react'; + +const PokemonQuery = graphql(` + query Po($id: ID!) { + pokemon(id: $id) { + id + fleeRate + ...pokemonFields + attacks { + special { + name + damage + } + } + weight { + minimum + maximum + } + name + __typename + } + } +`); + +const Pokemons = () => { + // @ts-expect-error + const [{ data: { pokemon: { fleeRate, weight: { minimum, maximum } } } }] = useQuery({ + query: PokemonQuery, + variables: { id: '' } + }); + + // @ts-expect-error + return ; +} + diff --git a/test/e2e/fixture-project-unused-fields/fixtures/property-access.tsx b/test/e2e/fixture-project-unused-fields/fixtures/property-access.tsx new file mode 100644 index 00000000..ec526ccb --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/fixtures/property-access.tsx @@ -0,0 +1,42 @@ +import { useQuery } from 'urql'; +import { graphql } from './gql'; +// @ts-expect-error +import { Pokemon } from './fragment'; +import * as React from 'react'; + +const PokemonQuery = graphql(` + query Po($id: ID!) { + pokemon(id: $id) { + id + fleeRate + ...pokemonFields + attacks { + special { + name + damage + } + } + weight { + minimum + maximum + } + name + __typename + } + } +`); + +const Pokemons = () => { + const [result] = useQuery({ + query: PokemonQuery, + variables: { id: '' } + }); + + const pokemon = result.data?.pokemon + console.log(result.data?.pokemon?.attacks && result.data?.pokemon?.attacks.special && result.data?.pokemon?.attacks.special[0] && result.data?.pokemon?.attacks.special[0].name) + console.log(pokemon?.name) + + // @ts-expect-error + return ; +} + diff --git a/test/e2e/fixture-project-unused-fields/gql/fragment-masking.ts b/test/e2e/fixture-project-unused-fields/gql/fragment-masking.ts new file mode 100644 index 00000000..a6a291c0 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/gql/fragment-masking.ts @@ -0,0 +1,85 @@ +import { + ResultOf, + DocumentTypeDecoration, + TypedDocumentNode, +} from '@graphql-typed-document-node/core'; +import { FragmentDefinitionNode } from 'graphql'; +import { Incremental } from './graphql'; + +export type FragmentType< + TDocumentType extends DocumentTypeDecoration +> = TDocumentType extends DocumentTypeDecoration + ? [TType] extends [{ ' $fragmentName'?: infer TKey }] + ? TKey extends string + ? { ' $fragmentRefs'?: { [key in TKey]: TType } } + : never + : never + : never; + +// return non-nullable if `fragmentType` is non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> +): TType; +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | null + | undefined +): TType | null | undefined; +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> +): ReadonlyArray; +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | ReadonlyArray>> + | null + | undefined +): ReadonlyArray | null | undefined; +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | ReadonlyArray>> + | null + | undefined +): TType | ReadonlyArray | null | undefined { + return fragmentType as any; +} + +export function makeFragmentData< + F extends DocumentTypeDecoration, + FT extends ResultOf +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType; +} +export function isFragmentReady( + queryNode: DocumentTypeDecoration, + fragmentNode: TypedDocumentNode, + data: + | FragmentType, any>> + | null + | undefined +): data is FragmentType { + const deferredFields = ( + queryNode as { + __meta__?: { deferredFields: Record }; + } + ).__meta__?.deferredFields; + + if (!deferredFields) return true; + + const fragDef = fragmentNode.definitions[0] as + | FragmentDefinitionNode + | undefined; + const fragName = fragDef?.name?.value; + + const fields = (fragName && deferredFields[fragName]) || []; + return fields.length > 0 && fields.every(field => data && field in data); +} diff --git a/test/e2e/fixture-project-unused-fields/gql/gql.ts b/test/e2e/fixture-project-unused-fields/gql/gql.ts new file mode 100644 index 00000000..edbe1b17 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/gql/gql.ts @@ -0,0 +1,54 @@ +/* eslint-disable */ +import * as types from './graphql'; +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + */ +const documents = { + '\n fragment pokemonFields on Pokemon {\n id\n name\n attacks {\n fast {\n damage\n name\n }\n }\n }\n': + types.PokemonFieldsFragmentDoc, + '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n': + types.PoDocument, +}; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. + */ +export function graphql(source: string): unknown; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n fragment pokemonFields on Pokemon {\n id\n name\n attacks {\n fast {\n damage\n name\n }\n }\n }\n' +): (typeof documents)['\n fragment pokemonFields on Pokemon {\n id\n name\n attacks {\n fast {\n damage\n name\n }\n }\n }\n']; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n' +): (typeof documents)['\n query Po($id: ID!) {\n pokemon(id: $id) {\n id\n fleeRate\n ...pokemonFields\n attacks {\n special {\n name\n damage\n }\n }\n weight {\n minimum\n maximum\n }\n name\n __typename\n }\n }\n']; + +export function graphql(source: string) { + return (documents as any)[source] ?? {}; +} + +export type DocumentType> = + TDocumentNode extends DocumentNode ? TType : never; diff --git a/test/e2e/fixture-project-unused-fields/gql/graphql.ts b/test/e2e/fixture-project-unused-fields/gql/graphql.ts new file mode 100644 index 00000000..6d5427bf --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/gql/graphql.ts @@ -0,0 +1,340 @@ +/* eslint-disable */ +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { + [K in keyof T]: T[K]; +}; +export type MakeOptional = Omit & { + [SubKey in K]?: Maybe; +}; +export type MakeMaybe = Omit & { + [SubKey in K]: Maybe; +}; +export type MakeEmpty< + T extends { [key: string]: unknown }, + K extends keyof T +> = { [_ in K]?: never }; +export type Incremental = + | T + | { + [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; + }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string }; + String: { input: string; output: string }; + Boolean: { input: boolean; output: boolean }; + Int: { input: number; output: number }; + Float: { input: number; output: number }; +}; + +/** Move a Pokémon can perform with the associated damage and type. */ +export type Attack = { + __typename?: 'Attack'; + damage?: Maybe; + name?: Maybe; + type?: Maybe; +}; + +export type AttacksConnection = { + __typename?: 'AttacksConnection'; + fast?: Maybe>>; + special?: Maybe>>; +}; + +/** Requirement that prevents an evolution through regular means of levelling up. */ +export type EvolutionRequirement = { + __typename?: 'EvolutionRequirement'; + amount?: Maybe; + name?: Maybe; +}; + +export type Pokemon = { + __typename?: 'Pokemon'; + attacks?: Maybe; + /** @deprecated And this is the reason why */ + classification?: Maybe; + evolutionRequirements?: Maybe>>; + evolutions?: Maybe>>; + /** Likelihood of an attempt to catch a Pokémon to fail. */ + fleeRate?: Maybe; + height?: Maybe; + id: Scalars['ID']['output']; + /** Maximum combat power a Pokémon may achieve at max level. */ + maxCP?: Maybe; + /** Maximum health points a Pokémon may achieve at max level. */ + maxHP?: Maybe; + name: Scalars['String']['output']; + resistant?: Maybe>>; + types?: Maybe>>; + weaknesses?: Maybe>>; + weight?: Maybe; +}; + +export type PokemonDimension = { + __typename?: 'PokemonDimension'; + maximum?: Maybe; + minimum?: Maybe; +}; + +/** Elemental property associated with either a Pokémon or one of their moves. */ +export enum PokemonType { + Bug = 'Bug', + Dark = 'Dark', + Dragon = 'Dragon', + Electric = 'Electric', + Fairy = 'Fairy', + Fighting = 'Fighting', + Fire = 'Fire', + Flying = 'Flying', + Ghost = 'Ghost', + Grass = 'Grass', + Ground = 'Ground', + Ice = 'Ice', + Normal = 'Normal', + Poison = 'Poison', + Psychic = 'Psychic', + Rock = 'Rock', + Steel = 'Steel', + Water = 'Water', +} + +export type Query = { + __typename?: 'Query'; + /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ + pokemon?: Maybe; + /** List out all Pokémon, optionally in pages */ + pokemons?: Maybe>>; +}; + +export type QueryPokemonArgs = { + id: Scalars['ID']['input']; +}; + +export type QueryPokemonsArgs = { + limit?: InputMaybe; + skip?: InputMaybe; +}; + +export type PokemonFieldsFragment = { + __typename?: 'Pokemon'; + id: string; + name: string; + attacks?: { + __typename?: 'AttacksConnection'; + fast?: Array<{ + __typename?: 'Attack'; + damage?: number | null; + name?: string | null; + } | null> | null; + } | null; +} & { ' $fragmentName'?: 'PokemonFieldsFragment' }; + +export type PoQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + +export type PoQuery = { + __typename?: 'Query'; + pokemon?: + | ({ + __typename: 'Pokemon'; + id: string; + fleeRate?: number | null; + name: string; + attacks?: { + __typename?: 'AttacksConnection'; + special?: Array<{ + __typename?: 'Attack'; + name?: string | null; + damage?: number | null; + } | null> | null; + } | null; + weight?: { + __typename?: 'PokemonDimension'; + minimum?: string | null; + maximum?: string | null; + } | null; + } & { + ' $fragmentRefs'?: { PokemonFieldsFragment: PokemonFieldsFragment }; + }) + | null; +}; + +export const PokemonFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'pokemonFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Pokemon' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'attacks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'fast' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'damage' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const PoDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Po' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'pokemon' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'id' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'id' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'fleeRate' } }, + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'pokemonFields' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'attacks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'special' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'damage' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'weight' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'minimum' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'maximum' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'pokemonFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Pokemon' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'attacks' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'fast' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'damage' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; diff --git a/test/e2e/fixture-project-unused-fields/gql/index.ts b/test/e2e/fixture-project-unused-fields/gql/index.ts new file mode 100644 index 00000000..c682b1e2 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/gql/index.ts @@ -0,0 +1,2 @@ +export * from './fragment-masking'; +export * from './gql'; diff --git a/test/e2e/fixture-project-unused-fields/package.json b/test/e2e/fixture-project-unused-fields/package.json new file mode 100644 index 00000000..6de88e72 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/package.json @@ -0,0 +1,15 @@ +{ + "name": "fixtures", + "private": true, + "dependencies": { + "graphql": "^16.0.0", + "@graphql-typed-document-node/core": "^3.0.0", + "@0no-co/graphqlsp": "workspace:*", + "@urql/core": "^4.0.4", + "urql": "^4.0.4" + }, + "devDependencies": { + "@types/react": "18.2.45", + "typescript": "^5.3.3" + } +} diff --git a/test/e2e/fixture-project-unused-fields/schema.graphql b/test/e2e/fixture-project-unused-fields/schema.graphql new file mode 100644 index 00000000..148f0b7c --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/schema.graphql @@ -0,0 +1,94 @@ +### This file was generated by Nexus Schema +### Do not make changes to this file directly + +""" +Move a Pokémon can perform with the associated damage and type. +""" +type Attack { + damage: Int + name: String + type: PokemonType +} + +type AttacksConnection { + fast: [Attack] + special: [Attack] +} + +""" +Requirement that prevents an evolution through regular means of levelling up. +""" +type EvolutionRequirement { + amount: Int + name: String +} + +type Pokemon { + attacks: AttacksConnection + classification: String @deprecated(reason: "And this is the reason why") + evolutionRequirements: [EvolutionRequirement] + evolutions: [Pokemon] + + """ + Likelihood of an attempt to catch a Pokémon to fail. + """ + fleeRate: Float + height: PokemonDimension + id: ID! + + """ + Maximum combat power a Pokémon may achieve at max level. + """ + maxCP: Int + + """ + Maximum health points a Pokémon may achieve at max level. + """ + maxHP: Int + name: String! + resistant: [PokemonType] + types: [PokemonType] + weaknesses: [PokemonType] + weight: PokemonDimension +} + +type PokemonDimension { + maximum: String + minimum: String +} + +""" +Elemental property associated with either a Pokémon or one of their moves. +""" +enum PokemonType { + Bug + Dark + Dragon + Electric + Fairy + Fighting + Fire + Flying + Ghost + Grass + Ground + Ice + Normal + Poison + Psychic + Rock + Steel + Water +} + +type Query { + """ + Get a single Pokémon by its ID, a three character long identifier padded with zeroes + """ + pokemon(id: ID!): Pokemon + + """ + List out all Pokémon, optionally in pages + """ + pokemons(limit: Int, skip: Int): [Pokemon] +} \ No newline at end of file diff --git a/test/e2e/fixture-project-unused-fields/tsconfig.json b/test/e2e/fixture-project-unused-fields/tsconfig.json new file mode 100644 index 00000000..dccc8f64 --- /dev/null +++ b/test/e2e/fixture-project-unused-fields/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "plugins": [ + { + "name": "@0no-co/graphqlsp", + "schema": "./schema.graphql", + "disableTypegen": true, + "trackFieldUsage": true, + "shouldCheckForColocatedFragments": false, + "template": "graphql", + "templateIsCallExpression": true + } + ], + "target": "es2016", + "jsx": "react-jsx", + "esModuleInterop": true, + "moduleResolution": "node", + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "exclude": ["node_modules", "fixtures"] +} diff --git a/test/e2e/unused-fieds.test.ts b/test/e2e/unused-fieds.test.ts new file mode 100644 index 00000000..57664c54 --- /dev/null +++ b/test/e2e/unused-fieds.test.ts @@ -0,0 +1,429 @@ +import { expect, afterAll, beforeAll, it, describe } from 'vitest'; +import { TSServer } from './server'; +import path from 'node:path'; +import fs from 'node:fs'; +import url from 'node:url'; +import ts from 'typescript/lib/tsserverlibrary'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +const projectPath = path.resolve(__dirname, 'fixture-project-unused-fields'); +describe('unused fields', () => { + const outfileDestructuringFromStart = path.join( + projectPath, + 'immediate-destructuring.tsx' + ); + const outfileDestructuring = path.join(projectPath, 'destructuring.tsx'); + const outfileFragmentDestructuring = path.join( + projectPath, + 'fragment-destructuring.tsx' + ); + const outfileFragment = path.join(projectPath, 'fragment.tsx'); + const outfilePropAccess = path.join(projectPath, 'property-access.tsx'); + + let server: TSServer; + beforeAll(async () => { + server = new TSServer(projectPath, { debugLog: false }); + + server.sendCommand('open', { + file: outfileDestructuring, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfileFragment, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfilePropAccess, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfileFragmentDestructuring, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfileDestructuringFromStart, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); + + server.sendCommand('updateOpen', { + openFiles: [ + { + file: outfileDestructuring, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/destructuring.tsx'), + 'utf-8' + ), + }, + { + file: outfileFragment, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/fragment.tsx'), + 'utf-8' + ), + }, + { + file: outfilePropAccess, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/property-access.tsx'), + 'utf-8' + ), + }, + { + file: outfileDestructuringFromStart, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/immediate-destructuring.tsx'), + 'utf-8' + ), + }, + { + file: outfileFragmentDestructuring, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/fragment-destructuring.tsx'), + 'utf-8' + ), + }, + ], + } satisfies ts.server.protocol.UpdateOpenRequestArgs); + + server.sendCommand('saveto', { + file: outfileDestructuring, + tmpfile: outfileDestructuring, + } satisfies ts.server.protocol.SavetoRequestArgs); + server.sendCommand('saveto', { + file: outfileFragment, + tmpfile: outfileFragment, + } satisfies ts.server.protocol.SavetoRequestArgs); + server.sendCommand('saveto', { + file: outfilePropAccess, + tmpfile: outfilePropAccess, + } satisfies ts.server.protocol.SavetoRequestArgs); + server.sendCommand('saveto', { + file: outfileFragmentDestructuring, + tmpfile: outfileFragmentDestructuring, + } satisfies ts.server.protocol.SavetoRequestArgs); + server.sendCommand('saveto', { + file: outfileDestructuringFromStart, + tmpfile: outfileDestructuringFromStart, + } satisfies ts.server.protocol.SavetoRequestArgs); + }); + + afterAll(() => { + try { + fs.unlinkSync(outfileDestructuring); + fs.unlinkSync(outfileFragment); + fs.unlinkSync(outfilePropAccess); + fs.unlinkSync(outfileFragmentDestructuring); + fs.unlinkSync(outfileDestructuringFromStart); + } catch {} + }); + + it('gives unused fields with fragments', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfileFragment + ); + const res = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfileFragment + ); + expect(res[0].body.diagnostics).toMatchInlineSnapshot(` + [ + { + "category": "warning", + "code": 52005, + "end": { + "line": 10, + "offset": 15, + }, + "start": { + "line": 10, + "offset": 9, + }, + "text": "Field 'attacks.fast.damage' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 11, + "offset": 13, + }, + "start": { + "line": 11, + "offset": 9, + }, + "text": "Field 'attacks.fast.name' is not used.", + }, + ] + `); + }, 30000); + + it('gives unused fields with fragments destructuring', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfileFragmentDestructuring + ); + const res = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfileFragmentDestructuring + ); + expect(res[0].body.diagnostics).toMatchInlineSnapshot(` + [ + { + "category": "warning", + "code": 52005, + "end": { + "line": 10, + "offset": 15, + }, + "start": { + "line": 10, + "offset": 9, + }, + "text": "Field 'attacks.fast.damage' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 11, + "offset": 13, + }, + "start": { + "line": 11, + "offset": 9, + }, + "text": "Field 'attacks.fast.name' is not used.", + }, + ] + `); + }, 30000); + + it('gives semantc diagnostics with property access', async () => { + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfilePropAccess + ); + const res = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfilePropAccess + ); + expect(res[0].body.diagnostics).toMatchInlineSnapshot(` + [ + { + "category": "warning", + "code": 52005, + "end": { + "line": 11, + "offset": 15, + }, + "start": { + "line": 11, + "offset": 7, + }, + "text": "Field 'pokemon.fleeRate' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 16, + "offset": 17, + }, + "start": { + "line": 16, + "offset": 11, + }, + "text": "Field 'pokemon.attacks.special.damage' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 20, + "offset": 16, + }, + "start": { + "line": 20, + "offset": 9, + }, + "text": "Field 'pokemon.weight.minimum' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 21, + "offset": 16, + }, + "start": { + "line": 21, + "offset": 9, + }, + "text": "Field 'pokemon.weight.maximum' is not used.", + }, + { + "category": "error", + "code": 2578, + "end": { + "line": 3, + "offset": 20, + }, + "start": { + "line": 3, + "offset": 1, + }, + "text": "Unused '@ts-expect-error' directive.", + }, + ] + `); + }, 30000); + + it('gives unused fields with destructuring', async () => { + const res = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfileDestructuring + ); + expect(res[0].body.diagnostics).toMatchInlineSnapshot(` + [ + { + "category": "warning", + "code": 52005, + "end": { + "line": 15, + "offset": 15, + }, + "start": { + "line": 15, + "offset": 11, + }, + "text": "Field 'pokemon.attacks.special.name' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 16, + "offset": 17, + }, + "start": { + "line": 16, + "offset": 11, + }, + "text": "Field 'pokemon.attacks.special.damage' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 23, + "offset": 11, + }, + "start": { + "line": 23, + "offset": 7, + }, + "text": "Field 'pokemon.name' is not used.", + }, + { + "category": "error", + "code": 2578, + "end": { + "line": 3, + "offset": 20, + }, + "start": { + "line": 3, + "offset": 1, + }, + "text": "Unused '@ts-expect-error' directive.", + }, + ] + `); + }, 30000); + + it('gives unused fields with immedaite destructuring', async () => { + const res = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfileDestructuringFromStart + ); + expect(res[0].body.diagnostics).toMatchInlineSnapshot(` + [ + { + "category": "warning", + "code": 52005, + "end": { + "line": 15, + "offset": 15, + }, + "start": { + "line": 15, + "offset": 11, + }, + "text": "Field 'pokemon.attacks.special.name' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 16, + "offset": 17, + }, + "start": { + "line": 16, + "offset": 11, + }, + "text": "Field 'pokemon.attacks.special.damage' is not used.", + }, + { + "category": "warning", + "code": 52005, + "end": { + "line": 23, + "offset": 11, + }, + "start": { + "line": 23, + "offset": 7, + }, + "text": "Field 'pokemon.name' is not used.", + }, + { + "category": "error", + "code": 2578, + "end": { + "line": 3, + "offset": 20, + }, + "start": { + "line": 3, + "offset": 1, + }, + "text": "Unused '@ts-expect-error' directive.", + }, + ] + `); + }, 30000); +});