fix(group): tighten gql-tag trust and PascalCase Document lookup

Only the identifier `gql` is a trusted interpolating tag. Underscored operation names now try the full pascal-case Document candidate graphql-codegen emits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergo Magyar 2026-09-09 13:17:24 +00:00
parent 826cae2cf3
commit dae5e75b28
2 changed files with 68 additions and 5 deletions

View file

@ -143,10 +143,15 @@ function substitutionIdentifier(substitution: Parser.SyntaxNode): string | 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';
return callee?.type === 'identifier' && callee.text === 'gql';
}
function pascalCaseGraphqlName(name: string): string {
return name
.split(/[^A-Za-z0-9]+/)
.filter((part) => part.length > 0)
.map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`)
.join('');
}
function uniqueStaticSource(
@ -558,7 +563,7 @@ function generatedCandidates(operation: OperationDefinitionNode): string[] {
const name = operation.name?.value;
if (!name) return [];
const exact = `${name}Document`;
const pascal = `${name[0]!.toUpperCase()}${name.slice(1)}Document`;
const pascal = `${pascalCaseGraphqlName(name)}Document`;
return exact === pascal ? [exact] : [exact, pascal];
}

View file

@ -570,6 +570,42 @@ class HealthResolver {
]);
});
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': `
@ -755,4 +791,26 @@ export const QDocument = String.raw\`query Q { q }\\n\${QFragmentDoc}\`;
expect(contracts).toEqual([]);
});
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([]);
});
});