diff --git a/gitnexus/src/core/group/extractors/graphql-extractor.ts b/gitnexus/src/core/group/extractors/graphql-extractor.ts index 4684533df..fcc4b8479 100644 --- a/gitnexus/src/core/group/extractors/graphql-extractor.ts +++ b/gitnexus/src/core/group/extractors/graphql-extractor.ts @@ -141,6 +141,14 @@ function substitutionIdentifier(substitution: Parser.SyntaxNode): string | null return expr.type === 'identifier' ? expr.text : null; } +function isGraphqlTagCall(call: Parser.SyntaxNode): boolean { + const callee = call.childForFieldName('function'); + if (!callee) return false; + if (callee.type === 'identifier') return callee.text === 'gql'; + if (callee.type !== 'member_expression') return false; + return callee.childForFieldName('property')?.text === 'gql'; +} + function uniqueStaticSource( name: string, declarators: GeneratedSymbolIndex, @@ -340,7 +348,14 @@ function staticGraphqlSource( if (value.type === 'call_expression') { const template = value.namedChildren.find((child) => child.type === 'template_string'); - return template ? staticGraphqlSource(template, declarators, resolving) : null; + if (!template) return null; + const hasSubstitution = template.namedChildren.some( + (child) => child.type === 'template_substitution', + ); + // Interpolated reconstruction is the cooked template. Only `gql` is + // treated as preserving that source; String.raw / unknown tags stay fail-closed. + if (hasSubstitution && !isGraphqlTagCall(value)) return null; + return staticGraphqlSource(template, declarators, resolving); } if (value.type !== 'new_expression') return null; diff --git a/gitnexus/test/unit/group/graphql-extractor.test.ts b/gitnexus/test/unit/group/graphql-extractor.test.ts index 56363d6e8..4ce42376f 100644 --- a/gitnexus/test/unit/group/graphql-extractor.test.ts +++ b/gitnexus/test/unit/group/graphql-extractor.test.ts @@ -733,4 +733,26 @@ export const QDocument = gql\`query Q { q }\\n\${QFragmentDoc}\`; }), ]); }); + + 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([]); + }); });