import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { parse } from 'graphql'; import Parser from 'tree-sitter'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CypherExecutor } from '../../../src/core/group/contract-extractor.js'; import { GraphqlExtractor, indexGeneratedDeclarators, } from '../../../src/core/group/extractors/graphql-extractor.js'; import type { RepoHandle } from '../../../src/core/group/types.js'; import { cleanupTempDir } from '../../helpers/test-db.js'; const tempDirs: string[] = []; async function makeRepo( files: Record, ): Promise<{ root: string; repo: RepoHandle }> { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-graphql-')); tempDirs.push(root); for (const [relative, content] of Object.entries(files)) { const absolute = path.join(root, relative); await fs.mkdir(path.dirname(absolute), { recursive: true }); await fs.writeFile(absolute, content, 'utf8'); } return { root, repo: { id: 'test', path: 'app', repoPath: root, storagePath: path.join(root, '.gitnexus') }, }; } function executor(symbols: Record>>): CypherExecutor { return async (_query, params = {}) => { const name = String(params.name ?? ''); const filePath = params.filePath ? `@${String(params.filePath)}` : ''; return symbols[`${name}${filePath}`] ?? symbols[name] ?? []; }; } function generatedDocument( operation: 'query' | 'mutation' | 'subscription', name: string, fields: string[], ): string { const selections = fields .map((field) => `{ kind: 'Field', name: { kind: 'Name', value: '${field}' } }`) .join(', '); return `{ kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: '${operation}', name: { kind: 'Name', value: '${name}' }, selectionSet: { kind: 'SelectionSet', selections: [${selections}] } }] }`; } describe('GraphqlExtractor', () => { afterEach(async () => { vi.restoreAllMocks(); await Promise.all(tempDirs.splice(0).map((dir) => cleanupTempDir(dir))); }); it('anchors NestJS providers and document consumers to exact real symbols', async () => { const { root, repo } = await makeRepo({ 'src/widget.resolver.ts': ` import { Resolver, Query as GqlQuery, Mutation, Subscription } from '@nestjs/graphql'; @Resolver() class WidgetResolver { @GqlQuery(() => Widget, { name: 'widget' }) fetchWidget() { return null; } @Mutation('saveWidget') save() { return null; } @Subscription() widgetChanged() { return null; } }`, 'src/widget.graphql': ` fragment WidgetRoot on Query { widget } query GetWidget { alias: widget ...WidgetRoot } mutation SaveWidget { saveWidget } subscription WatchWidget { widgetChanged } `, 'src/generated.ts': ` export const GetWidgetDocument = ${generatedDocument('query', 'GetWidget', ['widget'])}; export const SaveWidgetDocument = ${generatedDocument('mutation', 'SaveWidget', ['saveWidget'])}; export const WatchWidgetDocument = ${generatedDocument('subscription', 'WatchWidget', ['widgetChanged'])}; `, }); const run = executor({ 'fetchWidget@src/widget.resolver.ts': [ { uid: 'method:fetch', name: 'fetchWidget', filePath: 'src/widget.resolver.ts' }, ], 'save@src/widget.resolver.ts': [ { uid: 'method:save', name: 'save', filePath: 'src/widget.resolver.ts' }, ], 'widgetChanged@src/widget.resolver.ts': [ { uid: 'method:watch', name: 'widgetChanged', filePath: 'src/widget.resolver.ts' }, ], GetWidgetDocument: [ { uid: 'const:get', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, ], SaveWidgetDocument: [ { uid: 'const:save', name: 'SaveWidgetDocument', filePath: 'src/generated.ts' }, ], WatchWidgetDocument: [ { uid: 'const:watch', name: 'WatchWidgetDocument', filePath: 'src/generated.ts' }, ], }); const contracts = await new GraphqlExtractor().extract(run, root, repo); expect( contracts.map((contract) => [contract.contractId, contract.role, contract.symbolUid]), ).toEqual([ ['graphql::query::widget', 'provider', 'method:fetch'], ['graphql::mutation::saveWidget', 'provider', 'method:save'], ['graphql::subscription::widgetChanged', 'provider', 'method:watch'], ['graphql::query::widget', 'consumer', 'const:get'], ['graphql::mutation::saveWidget', 'consumer', 'const:save'], ['graphql::subscription::widgetChanged', 'consumer', 'const:watch'], ]); }); it('skips unproven decorators, ambiguous anchors, anonymous operations, and invalid documents', async () => { const { root, repo } = await makeRepo({ 'src/not-nest.ts': ` function Query(): MethodDecorator { return () => undefined; } class LocalResolver { @Query() localOnly() {} }`, 'src/ambiguous.resolver.ts': ` import { Query, Resolver } from '@nestjs/graphql'; @Resolver() class AmbiguousResolver { @Query() widget() {} }`, 'src/anonymous.graphql': `query { widget }`, 'src/invalid.graphql': `query Broken {`, 'src/missing.graphql': `query MissingGenerated { widget }`, }); const ambiguous = [ { uid: 'method:a', name: 'widget', filePath: 'src/ambiguous.resolver.ts' }, { uid: 'method:b', name: 'widget', filePath: 'src/ambiguous.resolver.ts' }, ]; const contracts = await new GraphqlExtractor().extract( executor({ 'widget@src/ambiguous.resolver.ts': ambiguous }), root, repo, ); expect(contracts).toEqual([]); }); it('rejects provider fields that are not GraphQL Names', async () => { const { root, repo } = await makeRepo({ 'src/invalid.resolver.ts': ` import { Query, Resolver } from '@nestjs/graphql'; @Resolver() class InvalidResolver { @Query('bad::field') separator() {} @Query('line\\nfeed') newline() {} @Query('right\\u202etoLeft') bidi() {} @Query('9startsWithDigit') digit() {} }`, }); let lookups = 0; const run: CypherExecutor = async () => { lookups++; return [{ uid: 'method:invalid', name: 'invalid', filePath: 'src/invalid.resolver.ts' }]; }; expect(await new GraphqlExtractor().extract(run, root, repo)).toEqual([]); expect(lookups).toBe(0); }); it('skips a provider file whose AST exceeds the traversal depth cap', async () => { const nested = `${'['.repeat(300)}0${']'.repeat(300)}`; const { root, repo } = await makeRepo({ 'src/deep.resolver.ts': ` import { Query, Resolver } from '@nestjs/graphql'; const nested = ${nested}; @Resolver() class DeepResolver { @Query() health() {} }`, }); let lookups = 0; expect( await new GraphqlExtractor().extract( async () => { lookups++; return [{ uid: 'method:health', name: 'health', filePath: 'src/deep.resolver.ts' }]; }, root, repo, ), ).toEqual([]); expect(lookups).toBe(0); }); it('uses an exact unique Document symbol when no generated hook exists', async () => { const { root, repo } = await makeRepo({ 'src/health.graphql': `query Health { health }`, 'src/generated/graphql.ts': `export const HealthDocument = ${generatedDocument('query', 'Health', ['health'])};`, }); const contracts = await new GraphqlExtractor().extract( executor({ HealthDocument: [ { uid: 'var:health', name: 'HealthDocument', filePath: 'src/generated/graphql.ts' }, ], }), root, repo, ); expect(contracts).toEqual([ expect.objectContaining({ contractId: 'graphql::query::health', role: 'consumer', symbolUid: 'var:health', symbolRef: { filePath: 'src/generated/graphql.ts', name: 'HealthDocument' }, }), ]); }); it('skips matching tokens that occur only in unrelated initializer metadata', async () => { const { root, repo } = await makeRepo({ 'src/health.graphql': `query Health { health }`, 'src/generated/graphql.ts': `export const HealthDocument = { metadata: { operation: 'Health', field: 'health' }, kind: 'NotADocument' };`, }); const contracts = await new GraphqlExtractor().extract( executor({ HealthDocument: [ { uid: 'var:health', name: 'HealthDocument', filePath: 'src/generated/graphql.ts' }, ], }), root, repo, ); expect(contracts).toEqual([]); }); it('fails closed when a generated Document initializer exceeds the AST depth budget', async () => { const { root, repo } = await makeRepo({ 'src/deep.graphql': `query Deep { health }`, 'src/generated.ts': `export const DeepDocument = ${'('.repeat(300)}${generatedDocument( 'query', 'Deep', ['health'], )}${')'.repeat(300)};`, }); const contracts = await new GraphqlExtractor().extract( executor({ DeepDocument: [{ uid: 'const:deep', name: 'DeepDocument', filePath: 'src/generated.ts' }], }), root, repo, ); expect(contracts).toEqual([]); }); it('fails closed for oversized, deeply nested, and excessive-operation documents', async () => { const nested = `${'... on Query { '.repeat(65)}health${' }'.repeat(65)}`; const manyOperations = Array.from( { length: 501 }, (_, index) => `query Op${index} { field${index} }`, ).join('\n'); const { root, repo } = await makeRepo({ 'src/oversized.graphql': `${' '.repeat(1_000_001)}query Huge { huge }`, 'src/deep.graphql': `query Deep { ${nested} }`, 'src/many.graphql': manyOperations, }); let lookups = 0; const run: CypherExecutor = async (_query, params = {}) => { lookups++; const name = String(params.name ?? ''); return name.startsWith('useOp') ? [{ uid: `fn:${name}`, name, filePath: 'src/generated.ts' }] : name === 'useDeepQuery' ? [{ uid: 'fn:deep', name, filePath: 'src/generated.ts' }] : []; }; const contracts = await new GraphqlExtractor().extract(run, root, repo); expect(contracts).toEqual([]); expect(contracts).not.toContainEqual(expect.objectContaining({ symbolUid: 'fn:deep' })); expect(lookups).toBe(0); }); it('keeps decorators across comments and supports decorated resolver properties', async () => { const { root, repo } = await makeRepo({ 'src/commented.resolver.ts': ` import { Query, Mutation, Resolver } from '@nestjs/graphql'; @Resolver() export class CommentedResolver { @Query() /** Public schema description. */ health() { return true; } @Mutation() // The comment is not an ownership boundary. save = async () => true; }`, }); const contracts = await new GraphqlExtractor().extract( executor({ 'health@src/commented.resolver.ts': [ { uid: 'method:health', name: 'health', filePath: 'src/commented.resolver.ts' }, ], 'save@src/commented.resolver.ts': [ { uid: 'property:save', name: 'save', filePath: 'src/commented.resolver.ts' }, ], }), root, repo, ); expect(contracts.map((contract) => contract.contractId)).toEqual([ 'graphql::query::health', 'graphql::mutation::save', ]); }); it('requires a top-level imported Resolver and skips dynamic field names', async () => { const { root, repo } = await makeRepo({ 'src/scoped.resolver.ts': ` import { Query, Resolver } from '@nestjs/graphql'; const FIELD = 'viewer'; const NAMES = { viewer: FIELD }; const name = FIELD; const opts = { name: FIELD }; class Helper { @Query() helperOnly() {} } function factory() { @Resolver() class NestedResolver { @Query() nestedOnly() {} } return NestedResolver; } @Resolver() class RealResolver { @Query(() => String, { name: FIELD }) dynamicName() {} @Query(() => String, { name: NAMES.viewer }) memberName() {} @Query(() => String, { name }) shorthandName() {} @Query(() => String, { ...opts }) spreadOptions() {} @Query(() => String, { name: \`get\${FIELD}\` }) interpolatedName() {} @Query(() => String, { name: 'get' + 'Widget' }) concatenatedName() {} @Query(() => String) stableName() {} }`, }); const lookedUp: string[] = []; const run: CypherExecutor = async (_query, params = {}) => { lookedUp.push(String(params.name)); return [ { uid: `method:${String(params.name)}`, name: String(params.name), filePath: 'src/scoped.resolver.ts', }, ]; }; const contracts = await new GraphqlExtractor().extract(run, root, repo); expect(lookedUp).toEqual(['stableName']); expect(contracts).toEqual([ expect.objectContaining({ contractId: 'graphql::query::stableName' }), ]); }); it('does not extract co-located spec resolvers', async () => { const { root, repo } = await makeRepo({ 'src/widget.resolver.spec.ts': ` import { Query, Resolver } from '@nestjs/graphql'; @Resolver() class MockResolver { @Query() widget() {} }`, }); let lookups = 0; const contracts = await new GraphqlExtractor().extract( async () => { lookups++; return [{ uid: 'method:mock', name: 'widget', filePath: 'src/widget.resolver.spec.ts' }]; }, root, repo, ); expect(contracts).toEqual([]); expect(lookups).toBe(0); }); it('indexes a generated declaration after more than 100000 earlier AST nodes', () => { const filler = { type: 'identifier', namedChildren: [] } as unknown as Parser.SyntaxNode; const name = { text: 'LateDocument' } as Parser.SyntaxNode; const value = { type: 'object', namedChildren: [] } as unknown as Parser.SyntaxNode; const declaration = { type: 'variable_declarator', namedChildren: [], childForFieldName: (field: string) => field === 'name' ? name : field === 'value' ? value : null, } as unknown as Parser.SyntaxNode; const root = { type: 'program', namedChildren: [...Array(100_001).fill(filler), declaration], } as unknown as Parser.SyntaxNode; expect(indexGeneratedDeclarators(root).get('LateDocument')).toEqual([value]); }); it('proves generated root fields through fragment spreads and inline fragments', async () => { const { root, repo } = await makeRepo({ 'src/widgets.graphql': ` query GetWidgets { widget ...MoreRoots ... on Query { inlineRoot } } fragment MoreRoots on Query { gadget } `, 'src/generated.ts': ` export const GetWidgetsDocument = ${JSON.stringify( parse(` query GetWidgets { widget ...MoreRoots ... on Query { inlineRoot } } fragment MoreRoots on Query { gadget } `), )};`, }); const contracts = await new GraphqlExtractor().extract( executor({ GetWidgetsDocument: [ { uid: 'const:widgets', name: 'GetWidgetsDocument', filePath: 'src/generated.ts' }, ], }), root, repo, ); expect(contracts.map((contract) => contract.contractId).sort()).toEqual([ 'graphql::query::gadget', 'graphql::query::inlineRoot', 'graphql::query::widget', ]); }); it('accepts static gql tags and TypedDocumentString initializers', async () => { const { root, repo } = await makeRepo({ 'src/tagged.graphql': `query Tagged { tagged }`, 'src/string.graphql': `query StringMode { stringMode }`, 'src/generated.ts': ` export const TaggedDocument = gql\`query Tagged { tagged }\`; export const StringModeDocument = new TypedDocumentString("query StringMode {\\n stringMode\\n}"); `, }); const contracts = await new GraphqlExtractor().extract( executor({ TaggedDocument: [ { uid: 'const:tagged', name: 'TaggedDocument', filePath: 'src/generated.ts' }, ], StringModeDocument: [ { uid: 'const:string', name: 'StringModeDocument', filePath: 'src/generated.ts' }, ], }), root, repo, ); expect(contracts.map((contract) => contract.symbolUid).sort()).toEqual([ 'const:string', 'const:tagged', ]); }); it('parses one generated module once and continues past a shadowed candidate', async () => { const { root, repo } = await makeRepo({ 'src/one.graphql': `query One { one }`, 'src/two.graphql': `query Two { two }`, 'src/generated.ts': ` function shadow() { const OneDocument = { kind: 'NotADocument' }; } export const OneDocument = ${generatedDocument('query', 'One', ['one'])}; export const TwoDocument = ${generatedDocument('query', 'Two', ['two'])}; `, }); const parseSpy = vi.spyOn(Parser.prototype, 'parse'); const contracts = await new GraphqlExtractor().extract( executor({ OneDocument: [{ uid: 'const:one', name: 'OneDocument', filePath: 'src/generated.ts' }], TwoDocument: [{ uid: 'const:two', name: 'TwoDocument', filePath: 'src/generated.ts' }], }), root, repo, ); expect(contracts.map((contract) => contract.symbolUid).sort()).toEqual([ 'const:one', 'const:two', ]); expect(parseSpy).toHaveBeenCalledTimes(1); }); it('binds NestJS providers at the 0-based graph startLine (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/health.resolver.ts': `import { Query, Mutation, Resolver } from '@nestjs/graphql'; @Resolver() class HealthResolver { @Query() health() { return 'ok'; } @Mutation() save = async () => true; }`, }); const graphStartLine: Record = { health: 4, save: 6 }; const startLines: Array<{ name: string; startLine: number }> = []; const run: CypherExecutor = async (_query, params = {}) => { const name = String(params.name); const startLine = Number(params.startLine); startLines.push({ name, startLine }); if (graphStartLine[name] !== startLine) return []; return [{ uid: `sym:${name}`, name, filePath: 'src/health.resolver.ts' }]; }; const contracts = await new GraphqlExtractor().extract(run, root, repo); expect(startLines).toEqual([ { name: 'health', startLine: 4 }, { name: 'save', startLine: 6 }, ]); expect(contracts.map((contract) => contract.contractId)).toEqual([ 'graphql::query::health', 'graphql::mutation::save', ]); }); it('tries the PascalCased Document name graphql-codegen emits (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/timeline.graphql': `query widgetTimeline { getWidget }`, 'src/generated.ts': `export const WidgetTimelineDocument = ${generatedDocument( 'query', 'widgetTimeline', ['getWidget'], )};`, }); const lookedUp: string[] = []; const run: CypherExecutor = async (_query, params = {}) => { const name = String(params.name ?? ''); lookedUp.push(name); return name === 'WidgetTimelineDocument' ? [ { uid: 'const:timeline', name: 'WidgetTimelineDocument', filePath: 'src/generated.ts', }, ] : []; }; const contracts = await new GraphqlExtractor().extract(run, root, repo); expect(lookedUp).toEqual(['widgetTimelineDocument', 'WidgetTimelineDocument']); expect(contracts).toEqual([ expect.objectContaining({ contractId: 'graphql::query::getWidget', role: 'consumer', symbolUid: 'const:timeline', }), ]); }); it('tries the full PascalCased Document name for underscored operations (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/widget.graphql': `query get_widget { getWidget }`, 'src/generated.ts': `export const GetWidgetDocument = ${generatedDocument( 'query', 'get_widget', ['getWidget'], )};`, }); const lookedUp: string[] = []; const run: CypherExecutor = async (_query, params = {}) => { const name = String(params.name ?? ''); lookedUp.push(name); return name === 'GetWidgetDocument' ? [ { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts', }, ] : []; }; const contracts = await new GraphqlExtractor().extract(run, root, repo); expect(lookedUp).toEqual(['get_widgetDocument', 'GetWidgetDocument']); expect(contracts).toEqual([ expect.objectContaining({ contractId: 'graphql::query::getWidget', role: 'consumer', symbolUid: 'const:widget', }), ]); }); it('inlines sibling ${FragmentDoc} interpolations to prove generated documents (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/get-widget.graphql': ` fragment widget on Widget { id } query GetWidget { getWidget { ...widget } } `, 'src/root-spread.graphql': ` fragment MoreRoots on Query { gadget } query GetWidgets { ...MoreRoots } `, 'src/generated.ts': ` export const WidgetFragmentDoc = /*#__PURE__*/ \` fragment widget on Widget { id } \`; export const GetWidgetDocument = /*#__PURE__*/ \` query GetWidget { getWidget { ...widget } } \${WidgetFragmentDoc}\`; export const MoreRootsFragmentDoc = /*#__PURE__*/ \` fragment MoreRoots on Query { gadget } \`; export const GetWidgetsDocument = gql\` query GetWidgets { ...MoreRoots } \${MoreRootsFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ GetWidgetDocument: [ { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, ], GetWidgetsDocument: [ { uid: 'const:widgets', name: 'GetWidgetsDocument', filePath: 'src/generated.ts' }, ], }), root, repo, ); expect(contracts.map((contract) => [contract.contractId, contract.symbolUid]).sort()).toEqual([ ['graphql::query::gadget', 'const:widgets'], ['graphql::query::getWidget', 'const:widget'], ]); }); it('fails closed for dynamic or cyclic generated interpolations (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/dynamic.graphql': `query Dynamic { dynamic }`, 'src/cycle.graphql': `query Cycle { cycle }`, 'src/generated.ts': ` export const DynamicDocument = \`query Dynamic { dynamic }\${foo.bar}\`; export const CycleFragmentDoc = \`\${CycleDocument}\`; export const CycleDocument = \`query Cycle { cycle }\${CycleFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ DynamicDocument: [ { uid: 'const:dynamic', name: 'DynamicDocument', filePath: 'src/generated.ts' }, ], CycleDocument: [ { uid: 'const:cycle', name: 'CycleDocument', filePath: 'src/generated.ts' }, ], }), root, repo, ); expect(contracts).toEqual([]); }); it('fails closed when sibling ${FragmentDoc} names resolve to two static sources (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/get-widget.graphql': `query GetWidget { getWidget }`, 'src/generated.ts': ` { const WidgetFragmentDoc = /*#__PURE__*/ \` fragment widget on Widget { id } \`; } export const WidgetFragmentDoc = /*#__PURE__*/ \` fragment widget on Widget { name } \`; export const GetWidgetDocument = /*#__PURE__*/ \` query GetWidget { getWidget { ...widget } } \${WidgetFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ GetWidgetDocument: [ { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, ], }), root, repo, ); expect(contracts).toEqual([]); }); it('fails closed when a FragmentDoc name has a static and a dynamic declarator (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/get-widget.graphql': `query GetWidget { getWidget }`, 'src/generated.ts': ` { const WidgetFragmentDoc = \`\${foo.bar}\`; } export const WidgetFragmentDoc = /*#__PURE__*/ \` fragment widget on Widget { id } \`; export const GetWidgetDocument = /*#__PURE__*/ \` query GetWidget { getWidget { ...widget } } \${WidgetFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ GetWidgetDocument: [ { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, ], }), root, repo, ); expect(contracts).toEqual([]); }); it('treats template line continuations as empty while inlining ${FragmentDoc} (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/q.graphql': `query Q { q }`, 'src/generated.ts': ` export const QFragmentDoc = /*#__PURE__*/ \` fragment extra on Query { q } \`; export const QDocument = gql\`query Q { q }\\ \${QFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], }), root, repo, ); expect(contracts).toEqual([ expect.objectContaining({ contractId: 'graphql::query::q', role: 'consumer', symbolUid: 'const:q', }), ]); }); it('fails closed for NonOctalDecimalEscapeSequence in interpolated gql templates (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/q.graphql': `query Q { q }`, 'src/generated.ts': ` export const QFragmentDoc = /*#__PURE__*/ \` fragment extra on Query { q } \`; export const QDocument = gql\`query Q { q }\\8\${QFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], }), root, repo, ); expect(contracts).toEqual([]); }); it('decodes escape_sequence nodes while inlining ${FragmentDoc} (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/q.graphql': `query Q { q }`, 'src/generated.ts': ` export const QFragmentDoc = /*#__PURE__*/ \` fragment extra on Query { q } \`; export const QDocument = gql\`query Q { q }\\n\${QFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], }), root, repo, ); expect(contracts).toEqual([ expect.objectContaining({ contractId: 'graphql::query::q', role: 'consumer', symbolUid: 'const:q', }), ]); }); it('fails closed for interpolated templates under a non-gql tag (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/q.graphql': `query Q { q }`, 'src/generated.ts': ` export const QFragmentDoc = /*#__PURE__*/ \` fragment extra on Query { q } \`; export const QDocument = String.raw\`query Q { q }\\n\${QFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], }), root, repo, ); expect(contracts).toEqual([]); }); it('memoizes interpolation sources so duplicated fragment layers stay linear (#3201)', async () => { const layers = 16; let generated = ` export const F0 = \`fragment f0 on Query { q }\`; { const F0 = \`fragment f0 on Query { q }\`; } `; for (let i = 1; i <= layers; i++) { const prev = `F${i - 1}`; const cur = `F${i}`; generated += ` export const ${cur} = \`\${${prev}}\`; { const ${cur} = \`\${${prev}}\`; } `; } generated += ` export const QDocument = gql\`query Q { q }\${F${layers}}\`; `; const { root, repo } = await makeRepo({ 'src/q.graphql': `query Q { q }`, 'src/generated.ts': generated, }); const contracts = await new GraphqlExtractor().extract( executor({ QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], }), root, repo, ); expect(contracts).toEqual([ expect.objectContaining({ contractId: 'graphql::query::q', role: 'consumer', symbolUid: 'const:q', }), ]); }); it('fails closed for interpolated templates under a member .gql tag (#3201)', async () => { const { root, repo } = await makeRepo({ 'src/q.graphql': `query Q { q }`, 'src/generated.ts': ` export const QFragmentDoc = /*#__PURE__*/ \` fragment extra on Query { q } \`; export const QDocument = formatter.gql\`query Q { q }\\n\${QFragmentDoc}\`; `, }); const contracts = await new GraphqlExtractor().extract( executor({ QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], }), root, repo, ); expect(contracts).toEqual([]); }); });