From dae5e75b28131f58a98ea81cc5aef9d65d4b0b81 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 9 Sep 2026 13:17:24 +0000 Subject: [PATCH] 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 --- .../group/extractors/graphql-extractor.ts | 15 +++-- .../test/unit/group/graphql-extractor.test.ts | 58 +++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/core/group/extractors/graphql-extractor.ts b/gitnexus/src/core/group/extractors/graphql-extractor.ts index fcc4b8479..43e3b79e6 100644 --- a/gitnexus/src/core/group/extractors/graphql-extractor.ts +++ b/gitnexus/src/core/group/extractors/graphql-extractor.ts @@ -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]; } diff --git a/gitnexus/test/unit/group/graphql-extractor.test.ts b/gitnexus/test/unit/group/graphql-extractor.test.ts index 4ce42376f..5f0eaf5a6 100644 --- a/gitnexus/test/unit/group/graphql-extractor.test.ts +++ b/gitnexus/test/unit/group/graphql-extractor.test.ts @@ -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([]); + }); });