From f4ff0291ce908fe28c8fdfc1f749caaef757ccb4 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 9 Sep 2026 13:26:56 +0000 Subject: [PATCH] fix(group): memoize GraphQL interpolation source resolution Avoid exponential re-walks when the same fragment name is declared twice at each layer of a ${FragmentDoc} chain. Co-authored-by: Cursor --- .../group/extractors/graphql-extractor.ts | 26 +++++++++---- .../test/unit/group/graphql-extractor.test.ts | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/gitnexus/src/core/group/extractors/graphql-extractor.ts b/gitnexus/src/core/group/extractors/graphql-extractor.ts index 43e3b79e6..1e55ab524 100644 --- a/gitnexus/src/core/group/extractors/graphql-extractor.ts +++ b/gitnexus/src/core/group/extractors/graphql-extractor.ts @@ -154,34 +154,45 @@ function pascalCaseGraphqlName(name: string): string { .join(''); } +type InterpolationCache = Map; + function uniqueStaticSource( name: string, declarators: GeneratedSymbolIndex, resolving: Set, + cache: InterpolationCache, ): string | null { + if (cache.has(name)) return cache.get(name) ?? null; if (resolving.has(name) || resolving.size >= MAX_GRAPHQL_TRAVERSAL_DEPTH) return null; const values = declarators.get(name) ?? []; - if (values.length === 0) return null; + if (values.length === 0) { + cache.set(name, null); + return null; + } resolving.add(name); const sources = new Set(); for (const value of values) { - const source = staticGraphqlSource(value, declarators, resolving); + const source = staticGraphqlSource(value, declarators, resolving, cache); // A dynamic or unprovable sibling makes the name ambiguous — do not pick // the one static spelling and ignore the rest. if (source === null) { resolving.delete(name); + cache.set(name, null); return null; } sources.add(source); } resolving.delete(name); - return sources.size === 1 ? [...sources][0]! : null; + const unique = sources.size === 1 ? [...sources][0]! : null; + cache.set(name, unique); + return unique; } function interpolatedTemplateSource( template: Parser.SyntaxNode, declarators: GeneratedSymbolIndex, resolving: Set, + cache: InterpolationCache, ): string | null { let out = ''; for (const child of template.namedChildren) { @@ -194,7 +205,7 @@ function interpolatedTemplateSource( } else if (child.type === 'template_substitution') { const name = substitutionIdentifier(child); if (!name) return null; - const inlined = uniqueStaticSource(name, declarators, resolving); + const inlined = uniqueStaticSource(name, declarators, resolving, cache); if (inlined === null) return null; out += inlined; } else { @@ -331,6 +342,7 @@ function staticGraphqlSource( initializer: Parser.SyntaxNode, declarators?: GeneratedSymbolIndex, resolving: Set = new Set(), + cache: InterpolationCache = new Map(), ): string | null { const value = unwrapExpression(initializer); if (value.type === 'string') { @@ -348,7 +360,7 @@ function staticGraphqlSource( (child) => child.type === 'template_substitution', ); if (!hasSubstitution) return unquote(value.text); - return declarators ? interpolatedTemplateSource(value, declarators, resolving) : null; + return declarators ? interpolatedTemplateSource(value, declarators, resolving, cache) : null; } if (value.type === 'call_expression') { @@ -360,7 +372,7 @@ function staticGraphqlSource( // 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); + return staticGraphqlSource(template, declarators, resolving, cache); } if (value.type !== 'new_expression') return null; @@ -368,7 +380,7 @@ function staticGraphqlSource( if (!constructor || !constructor.text.endsWith('TypedDocumentString')) return null; const args = value.childForFieldName('arguments'); const first = args?.namedChildren[0]; - return first ? staticGraphqlSource(first, declarators, resolving) : null; + return first ? staticGraphqlSource(first, declarators, resolving, cache) : null; } export function hasGeneratedDocumentProof( diff --git a/gitnexus/test/unit/group/graphql-extractor.test.ts b/gitnexus/test/unit/group/graphql-extractor.test.ts index 5f0eaf5a6..85e305454 100644 --- a/gitnexus/test/unit/group/graphql-extractor.test.ts +++ b/gitnexus/test/unit/group/graphql-extractor.test.ts @@ -792,6 +792,45 @@ export const QDocument = String.raw\`query Q { q }\\n\${QFragmentDoc}\`; 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 }`,