From 7558d3185c736997e82b57c1974c93a361621325 Mon Sep 17 00:00:00 2001 From: takumiyoshikawa Date: Tue, 9 Dec 2025 20:08:42 +0900 Subject: [PATCH 1/3] Detect fragment usage in maskFragments() calls --- packages/graphqlsp/src/ast/checks.ts | 10 +++++ packages/graphqlsp/src/ast/index.ts | 15 +++++++ packages/graphqlsp/src/diagnostics.ts | 19 ++++++++ .../fixture-project-tada/fixtures/graphql.ts | 2 +- .../fixtures/used-fragment-mask.ts | 7 +++ test/e2e/fixture-project-tada/graphql.ts | 2 +- test/e2e/tada.test.ts | 44 +++++++++++++++++++ 7 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts diff --git a/packages/graphqlsp/src/ast/checks.ts b/packages/graphqlsp/src/ast/checks.ts index 73014dc8..17f9e980 100644 --- a/packages/graphqlsp/src/ast/checks.ts +++ b/packages/graphqlsp/src/ast/checks.ts @@ -123,3 +123,13 @@ export const getSchemaName = ( } return null; }; + +/** Checks if node is a maskFragments() call */ +export const isMaskFragmentsCall = ( + node: ts.Node +): node is ts.CallExpression => { + if (!ts.isCallExpression(node)) return false; + if (!ts.isIdentifier(node.expression)) return false; + // Only checks function name, not whether it's from gql.tada + return node.expression.escapedText === 'maskFragments'; +}; diff --git a/packages/graphqlsp/src/ast/index.ts b/packages/graphqlsp/src/ast/index.ts index e6c8c9c8..68bbbf60 100644 --- a/packages/graphqlsp/src/ast/index.ts +++ b/packages/graphqlsp/src/ast/index.ts @@ -327,6 +327,21 @@ export function findAllImports( return sourceFile.statements.filter(ts.isImportDeclaration); } +export function findAllMaskFragmentsCalls( + sourceFile: ts.SourceFile +): Array { + const result: Array = []; + + function find(node: ts.Node): void { + if (checks.isMaskFragmentsCall(node)) { + result.push(node); + } + ts.forEachChild(node, find); + } + find(sourceFile); + return result; +} + export function bubbleUpTemplate(node: ts.Node): ts.Node { while ( ts.isNoSubstitutionTemplateLiteral(node) || diff --git a/packages/graphqlsp/src/diagnostics.ts b/packages/graphqlsp/src/diagnostics.ts index 04e1c034..b86a633f 100644 --- a/packages/graphqlsp/src/diagnostics.ts +++ b/packages/graphqlsp/src/diagnostics.ts @@ -15,6 +15,7 @@ import { findAllCallExpressions, findAllPersistedCallExpressions, findAllTaggedTemplateNodes, + findAllMaskFragmentsCalls, getSource, unrollFragment, } from './ast'; @@ -292,6 +293,7 @@ export function getGraphQLDiagnostics( if (isCallExpression && shouldCheckForColocatedFragments) { const moduleSpecifierToFragments = getColocatedFragmentNames(source, info); + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); const usedFragments = new Set(); nodes.forEach(({ node }) => { @@ -307,6 +309,23 @@ export function getGraphQLDiagnostics( } catch (e) {} }); + // check for maskFragments() calls + const maskFragmentsCalls = findAllMaskFragmentsCalls(source); + maskFragmentsCalls.forEach(call => { + const firstArg = call.arguments[0]; + if (!firstArg) return; + + // Handle array of fragments: maskFragments([Fragment1, Fragment2], data) + if (ts.isArrayLiteralExpression(firstArg)) { + firstArg.elements.forEach(element => { + if (ts.isIdentifier(element)) { + const fragmentDefs = unrollFragment(element, info, typeChecker); + fragmentDefs.forEach(def => usedFragments.add(def.name.value)); + } + }); + } + }); + Object.keys(moduleSpecifierToFragments).forEach(moduleSpecifier => { const { fragments: fragmentNames, diff --git a/test/e2e/fixture-project-tada/fixtures/graphql.ts b/test/e2e/fixture-project-tada/fixtures/graphql.ts index 051974ad..1f57962c 100644 --- a/test/e2e/fixture-project-tada/fixtures/graphql.ts +++ b/test/e2e/fixture-project-tada/fixtures/graphql.ts @@ -6,4 +6,4 @@ export const graphql = initGraphQLTada<{ }>(); export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada'; -export { readFragment } from 'gql.tada'; +export { readFragment, maskFragments } from 'gql.tada'; diff --git a/test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts b/test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts new file mode 100644 index 00000000..11a92953 --- /dev/null +++ b/test/e2e/fixture-project-tada/fixtures/used-fragment-mask.ts @@ -0,0 +1,7 @@ +import { maskFragments } from './graphql'; +import { Pokemon, PokemonFields } from './fragment'; + +const data = { id: '1', name: 'Pikachu', fleeRate: 0.1 }; +const x = maskFragments([PokemonFields], data); + +console.log(Pokemon); diff --git a/test/e2e/fixture-project-tada/graphql.ts b/test/e2e/fixture-project-tada/graphql.ts index 9d451c12..b15bb840 100644 --- a/test/e2e/fixture-project-tada/graphql.ts +++ b/test/e2e/fixture-project-tada/graphql.ts @@ -6,4 +6,4 @@ export const graphql = initGraphQLTada<{ }>(); export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada'; -export { readFragment } from 'gql.tada'; +export { readFragment, maskFragments } from 'gql.tada'; diff --git a/test/e2e/tada.test.ts b/test/e2e/tada.test.ts index 1130a565..482c7c7e 100644 --- a/test/e2e/tada.test.ts +++ b/test/e2e/tada.test.ts @@ -12,6 +12,10 @@ describe('Fragment + operations', () => { const outfileCombo = path.join(projectPath, 'simple.ts'); const outfileTypeCondition = path.join(projectPath, 'type-condition.ts'); const outfileUnusedFragment = path.join(projectPath, 'unused-fragment.ts'); + const outfileUsedFragmentMask = path.join( + projectPath, + 'used-fragment-mask.ts' + ); const outfileCombinations = path.join(projectPath, 'fragment.ts'); let server: TSServer; @@ -38,6 +42,11 @@ describe('Fragment + operations', () => { fileContent: '// empty', scriptKindName: 'TS', } satisfies ts.server.protocol.OpenRequestArgs); + server.sendCommand('open', { + file: outfileUsedFragmentMask, + fileContent: '// empty', + scriptKindName: 'TS', + } satisfies ts.server.protocol.OpenRequestArgs); server.sendCommand('updateOpen', { openFiles: [ @@ -69,6 +78,13 @@ describe('Fragment + operations', () => { 'utf-8' ), }, + { + file: outfileUsedFragmentMask, + fileContent: fs.readFileSync( + path.join(projectPath, 'fixtures/used-fragment-mask.ts'), + 'utf-8' + ), + }, ], } satisfies ts.server.protocol.UpdateOpenRequestArgs); @@ -88,11 +104,16 @@ describe('Fragment + operations', () => { file: outfileUnusedFragment, tmpfile: outfileUnusedFragment, } satisfies ts.server.protocol.SavetoRequestArgs); + server.sendCommand('saveto', { + file: outfileUsedFragmentMask, + tmpfile: outfileUsedFragmentMask, + } satisfies ts.server.protocol.SavetoRequestArgs); }); afterAll(() => { try { fs.unlinkSync(outfileUnusedFragment); + fs.unlinkSync(outfileUsedFragmentMask); fs.unlinkSync(outfileCombinations); fs.unlinkSync(outfileCombo); fs.unlinkSync(outfileTypeCondition); @@ -386,6 +407,29 @@ List out all Pokémon, optionally in pages` `); }, 30000); + it('should not warn about unused fragments when using maskFragments', async () => { + server.sendCommand('saveto', { + file: outfileUsedFragmentMask, + tmpfile: outfileUsedFragmentMask, + } satisfies ts.server.protocol.SavetoRequestArgs); + + await server.waitForResponse( + e => + e.type === 'event' && + e.event === 'semanticDiag' && + e.body?.file === outfileUsedFragmentMask + ); + + const res = server.responses.filter( + resp => + resp.type === 'event' && + resp.event === 'semanticDiag' && + resp.body?.file === outfileUsedFragmentMask + ); + // Should have no diagnostics about unused fragments since maskFragments uses them + expect(res[0].body.diagnostics).toMatchInlineSnapshot(`[]`); + }, 30000); + it('gives quick-info at start of word (#15)', async () => { server.send({ seq: 11, From ef083b7867cf268c9ee3fb5623294985a0997076 Mon Sep 17 00:00:00 2001 From: takumiyoshikawa Date: Tue, 9 Dec 2025 20:16:20 +0900 Subject: [PATCH 2/3] add changeset --- .changeset/lucky-friends-beg.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lucky-friends-beg.md diff --git a/.changeset/lucky-friends-beg.md b/.changeset/lucky-friends-beg.md new file mode 100644 index 00000000..dc622d10 --- /dev/null +++ b/.changeset/lucky-friends-beg.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': patch +--- + +Detect fragment usage in maskFragments() calls to prevent false positive unused fragment warnings From 214d770a709bd6f95707eeeaf83cddf39968d75d Mon Sep 17 00:00:00 2001 From: yoshi Date: Tue, 9 Dec 2025 22:41:42 +0900 Subject: [PATCH 3/3] Update .changeset/lucky-friends-beg.md Co-authored-by: Jovi De Croock --- .changeset/lucky-friends-beg.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lucky-friends-beg.md b/.changeset/lucky-friends-beg.md index dc622d10..6df3ab0a 100644 --- a/.changeset/lucky-friends-beg.md +++ b/.changeset/lucky-friends-beg.md @@ -2,4 +2,4 @@ '@0no-co/graphqlsp': patch --- -Detect fragment usage in maskFragments() calls to prevent false positive unused fragment warnings +Detect fragment usage in `maskFragments` calls to prevent false positive unused fragment warnings