fix(group): only inline interpolated templates under a gql tag

Cooked reconstruction is not the runtime value for String.raw or unknown tags, so those interpolations stay fail-closed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergo Magyar 2026-09-09 13:08:59 +00:00
parent ffe85ed1da
commit 826cae2cf3
2 changed files with 38 additions and 1 deletions

View file

@ -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;

View file

@ -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([]);
});
});