Mirror: TypeScript LSP plugin that finds GraphQL documents in your code and provides diagnostics, auto-complete and hover-information.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix: detecting fragment usage in maskFragments() (#379)

Co-authored-by: Jovi De Croock <decroockjovi@gmail.com>

authored by

yoshi
Jovi De Croock
and committed by
GitHub
10b5aba7 2a165394

+102 -2
+5
.changeset/lucky-friends-beg.md
··· 1 + --- 2 + '@0no-co/graphqlsp': patch 3 + --- 4 + 5 + Detect fragment usage in `maskFragments` calls to prevent false positive unused fragment warnings
+10
packages/graphqlsp/src/ast/checks.ts
··· 123 123 } 124 124 return null; 125 125 }; 126 + 127 + /** Checks if node is a maskFragments() call */ 128 + export const isMaskFragmentsCall = ( 129 + node: ts.Node 130 + ): node is ts.CallExpression => { 131 + if (!ts.isCallExpression(node)) return false; 132 + if (!ts.isIdentifier(node.expression)) return false; 133 + // Only checks function name, not whether it's from gql.tada 134 + return node.expression.escapedText === 'maskFragments'; 135 + };
+15
packages/graphqlsp/src/ast/index.ts
··· 327 327 return sourceFile.statements.filter(ts.isImportDeclaration); 328 328 } 329 329 330 + export function findAllMaskFragmentsCalls( 331 + sourceFile: ts.SourceFile 332 + ): Array<ts.CallExpression> { 333 + const result: Array<ts.CallExpression> = []; 334 + 335 + function find(node: ts.Node): void { 336 + if (checks.isMaskFragmentsCall(node)) { 337 + result.push(node); 338 + } 339 + ts.forEachChild(node, find); 340 + } 341 + find(sourceFile); 342 + return result; 343 + } 344 + 330 345 export function bubbleUpTemplate(node: ts.Node): ts.Node { 331 346 while ( 332 347 ts.isNoSubstitutionTemplateLiteral(node) ||
+19
packages/graphqlsp/src/diagnostics.ts
··· 15 15 findAllCallExpressions, 16 16 findAllPersistedCallExpressions, 17 17 findAllTaggedTemplateNodes, 18 + findAllMaskFragmentsCalls, 18 19 getSource, 19 20 unrollFragment, 20 21 } from './ast'; ··· 292 293 293 294 if (isCallExpression && shouldCheckForColocatedFragments) { 294 295 const moduleSpecifierToFragments = getColocatedFragmentNames(source, info); 296 + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); 295 297 296 298 const usedFragments = new Set(); 297 299 nodes.forEach(({ node }) => { ··· 305 307 }, 306 308 }); 307 309 } catch (e) {} 310 + }); 311 + 312 + // check for maskFragments() calls 313 + const maskFragmentsCalls = findAllMaskFragmentsCalls(source); 314 + maskFragmentsCalls.forEach(call => { 315 + const firstArg = call.arguments[0]; 316 + if (!firstArg) return; 317 + 318 + // Handle array of fragments: maskFragments([Fragment1, Fragment2], data) 319 + if (ts.isArrayLiteralExpression(firstArg)) { 320 + firstArg.elements.forEach(element => { 321 + if (ts.isIdentifier(element)) { 322 + const fragmentDefs = unrollFragment(element, info, typeChecker); 323 + fragmentDefs.forEach(def => usedFragments.add(def.name.value)); 324 + } 325 + }); 326 + } 308 327 }); 309 328 310 329 Object.keys(moduleSpecifierToFragments).forEach(moduleSpecifier => {
+1 -1
test/e2e/fixture-project-tada/fixtures/graphql.ts
··· 6 6 }>(); 7 7 8 8 export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada'; 9 - export { readFragment } from 'gql.tada'; 9 + export { readFragment, maskFragments } from 'gql.tada';
+7
test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts
··· 1 + import { maskFragments } from './graphql'; 2 + import { Pokemon, PokemonFields } from './fragment'; 3 + 4 + const data = { id: '1', name: 'Pikachu', fleeRate: 0.1 }; 5 + const x = maskFragments([PokemonFields], data); 6 + 7 + console.log(Pokemon);
+1 -1
test/e2e/fixture-project-tada/graphql.ts
··· 6 6 }>(); 7 7 8 8 export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada'; 9 - export { readFragment } from 'gql.tada'; 9 + export { readFragment, maskFragments } from 'gql.tada';
+44
test/e2e/tada.test.ts
··· 12 12 const outfileCombo = path.join(projectPath, 'simple.ts'); 13 13 const outfileTypeCondition = path.join(projectPath, 'type-condition.ts'); 14 14 const outfileUnusedFragment = path.join(projectPath, 'unused-fragment.ts'); 15 + const outfileUsedFragmentMask = path.join( 16 + projectPath, 17 + 'used-fragment-mask.ts' 18 + ); 15 19 const outfileCombinations = path.join(projectPath, 'fragment.ts'); 16 20 17 21 let server: TSServer; ··· 35 39 } satisfies ts.server.protocol.OpenRequestArgs); 36 40 server.sendCommand('open', { 37 41 file: outfileUnusedFragment, 42 + fileContent: '// empty', 43 + scriptKindName: 'TS', 44 + } satisfies ts.server.protocol.OpenRequestArgs); 45 + server.sendCommand('open', { 46 + file: outfileUsedFragmentMask, 38 47 fileContent: '// empty', 39 48 scriptKindName: 'TS', 40 49 } satisfies ts.server.protocol.OpenRequestArgs); ··· 69 78 'utf-8' 70 79 ), 71 80 }, 81 + { 82 + file: outfileUsedFragmentMask, 83 + fileContent: fs.readFileSync( 84 + path.join(projectPath, 'fixtures/used-fragment-mask.ts'), 85 + 'utf-8' 86 + ), 87 + }, 72 88 ], 73 89 } satisfies ts.server.protocol.UpdateOpenRequestArgs); 74 90 ··· 88 104 file: outfileUnusedFragment, 89 105 tmpfile: outfileUnusedFragment, 90 106 } satisfies ts.server.protocol.SavetoRequestArgs); 107 + server.sendCommand('saveto', { 108 + file: outfileUsedFragmentMask, 109 + tmpfile: outfileUsedFragmentMask, 110 + } satisfies ts.server.protocol.SavetoRequestArgs); 91 111 }); 92 112 93 113 afterAll(() => { 94 114 try { 95 115 fs.unlinkSync(outfileUnusedFragment); 116 + fs.unlinkSync(outfileUsedFragmentMask); 96 117 fs.unlinkSync(outfileCombinations); 97 118 fs.unlinkSync(outfileCombo); 98 119 fs.unlinkSync(outfileTypeCondition); ··· 384 405 }, 385 406 ] 386 407 `); 408 + }, 30000); 409 + 410 + it('should not warn about unused fragments when using maskFragments', async () => { 411 + server.sendCommand('saveto', { 412 + file: outfileUsedFragmentMask, 413 + tmpfile: outfileUsedFragmentMask, 414 + } satisfies ts.server.protocol.SavetoRequestArgs); 415 + 416 + await server.waitForResponse( 417 + e => 418 + e.type === 'event' && 419 + e.event === 'semanticDiag' && 420 + e.body?.file === outfileUsedFragmentMask 421 + ); 422 + 423 + const res = server.responses.filter( 424 + resp => 425 + resp.type === 'event' && 426 + resp.event === 'semanticDiag' && 427 + resp.body?.file === outfileUsedFragmentMask 428 + ); 429 + // Should have no diagnostics about unused fragments since maskFragments uses them 430 + expect(res[0].body.diagnostics).toMatchInlineSnapshot(`[]`); 387 431 }, 30000); 388 432 389 433 it('gives quick-info at start of word (#15)', async () => {