diff --git a/gitnexus/src/core/group/extractors/graphql-extractor.ts b/gitnexus/src/core/group/extractors/graphql-extractor.ts index efafe4e2e..550a06155 100644 --- a/gitnexus/src/core/group/extractors/graphql-extractor.ts +++ b/gitnexus/src/core/group/extractors/graphql-extractor.ts @@ -92,6 +92,134 @@ function unquote(text: string): string | null { return value.includes('${') ? null : value; } +const SIMPLE_TEMPLATE_ESCAPES: Record = { + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '0': '\0', + "'": "'", + '"': '"', + '\\': '\\', + '`': '`', +}; + +/** Graph Method `startLine` is the 0-based wrapper row (see line-base.ts). */ +function providerMemberStartLine(member: Parser.SyntaxNode): number { + // Class-field arrows are `@declaration.property`; parse-worker falls back to + // the `public_field_definition` wrapper (decorator row when it is a child), + // not the initializer. Do not probe the `value` child. + return member.startPosition.row; +} + +/** tree-sitter `escape_sequence.text` is the source spelling (`\\n`), not the JS value. */ +function decodeEscapeSequence(text: string): string | null { + if (!text.startsWith('\\') || text.length < 2) return null; + const escaped = text.slice(1); + // Tagged-template cooked value: LineContinuation is empty; `\8`/`\9` and + // LegacyOctalEscapeSequence (`\1`–`\7`, `\00`…) make cooked undefined. + if (/^[\n\r\u2028\u2029]$/.test(escaped) || escaped === '\r\n') return ''; + if (/^[1-9]$/.test(escaped) || /^[0-7]{2,3}$/.test(escaped)) return null; + if (escaped.length === 1) return SIMPLE_TEMPLATE_ESCAPES[escaped] ?? escaped; + if (escaped[0] === 'x' && /^[0-9A-Fa-f]{2}$/.test(escaped.slice(1))) { + return String.fromCharCode(Number.parseInt(escaped.slice(1), 16)); + } + if (escaped.startsWith('u{') && escaped.endsWith('}')) { + const hex = escaped.slice(2, -1); + if (!/^[0-9A-Fa-f]{1,6}$/.test(hex)) return null; + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10ffff) return null; + return String.fromCodePoint(codePoint); + } + if (escaped[0] === 'u' && /^[0-9A-Fa-f]{4}$/.test(escaped.slice(1))) { + return String.fromCharCode(Number.parseInt(escaped.slice(1), 16)); + } + return null; +} + +function substitutionIdentifier(substitution: Parser.SyntaxNode): string | null { + if (substitution.namedChildren.length !== 1) return null; + const expr = unwrapExpression(substitution.namedChildren[0]); + return expr.type === 'identifier' ? expr.text : null; +} + +function isGraphqlTagCall(call: Parser.SyntaxNode): boolean { + const callee = call.childForFieldName('function'); + 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(''); +} + +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) { + cache.set(name, null); + return null; + } + resolving.add(name); + const sources = new Set(); + for (const value of values) { + 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); + 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) { + if (child.type === 'string_fragment') { + out += child.text; + } else if (child.type === 'escape_sequence') { + const decoded = decodeEscapeSequence(child.text); + if (decoded === null) return null; + out += decoded; + } else if (child.type === 'template_substitution') { + const name = substitutionIdentifier(child); + if (!name) return null; + const inlined = uniqueStaticSource(name, declarators, resolving, cache); + if (inlined === null) return null; + out += inlined; + } else { + return null; + } + if (out.length > MAX_GRAPHQL_TOKENS) return null; + } + return out; +} + function unwrapExpression(node: Parser.SyntaxNode): Parser.SyntaxNode { let current = node; while ( @@ -214,7 +342,12 @@ function parsedDocumentProof( return false; } -function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null { +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') { if (value.text.startsWith('"')) { @@ -226,11 +359,24 @@ function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null { } return unquote(value.text); } - if (value.type === 'template_string') return unquote(value.text); + if (value.type === 'template_string') { + const hasSubstitution = value.namedChildren.some( + (child) => child.type === 'template_substitution', + ); + if (!hasSubstitution) return unquote(value.text); + return declarators ? interpolatedTemplateSource(value, declarators, resolving, cache) : null; + } if (value.type === 'call_expression') { const template = value.namedChildren.find((child) => child.type === 'template_string'); - return template ? unquote(template.text) : 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, cache); } if (value.type !== 'new_expression') return null; @@ -238,7 +384,7 @@ function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null { if (!constructor || !constructor.text.endsWith('TypedDocumentString')) return null; const args = value.childForFieldName('arguments'); const first = args?.namedChildren[0]; - return first ? staticGraphqlSource(first) : null; + return first ? staticGraphqlSource(first, declarators, resolving, cache) : null; } export function hasGeneratedDocumentProof( @@ -246,9 +392,10 @@ export function hasGeneratedDocumentProof( operationKind: GraphqlOperationKind, operationName: string, requiredFields: readonly string[], + declarators?: GeneratedSymbolIndex, ): boolean { if (!withinGeneratedAstBudget(initializer)) return false; - const staticSource = staticGraphqlSource(initializer); + const staticSource = staticGraphqlSource(initializer, declarators); if (staticSource !== null) { return parsedDocumentProof(staticSource, operationKind, operationName, requiredFields); } @@ -430,7 +577,10 @@ function rootFields( function generatedCandidates(operation: OperationDefinitionNode): string[] { const name = operation.name?.value; - return name ? [`${name}Document`] : []; + if (!name) return []; + const exact = `${name}Document`; + const pascal = `${pascalCaseGraphqlName(name)}Document`; + return exact === pascal ? [exact] : [exact, pascal]; } async function generatedDocumentMatches( @@ -450,7 +600,13 @@ async function generatedDocumentMatches( const index = await pendingIndex; const values = index?.get(symbol.name) ?? []; return values.some((value) => - hasGeneratedDocumentProof(value, operationKind, operationName, requiredFields), + hasGeneratedDocumentProof( + value, + operationKind, + operationName, + requiredFields, + index ?? undefined, + ), ); } @@ -593,11 +749,7 @@ export class GraphqlExtractor implements ContractExtractor { await dbExecutor(RESOLVE_METHOD_QUERY, { name: methodName, filePath, - startLine: - member.type === 'public_field_definition' - ? (member.childForFieldName('value')?.startPosition.row ?? - member.startPosition.row) + 1 - : member.startPosition.row + 1, + startLine: providerMemberStartLine(member), }), ); if (!symbol) continue; diff --git a/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts b/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts index 283f68842..4d1d50802 100644 --- a/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts +++ b/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts @@ -7,9 +7,11 @@ import { closeLbug, executeParameterized } from '../../../src/core/lbug/pool-ada import type { GroupConfig, RepoHandle } from '../../../src/core/group/types.js'; import { withTestLbugDB } from '../../helpers/test-indexed-db.js'; +// Graph Method.startLine is 0-based (tree-sitter row). `health()` is source line 5. const SEED = [ - `CREATE (:Method {id:'method:health', name:'health', filePath:'src/health.resolver.ts', startLine:5, endLine:5, content:'', description:''})`, - `CREATE (:Const {id:'const:health-document', name:'HealthDocument', filePath:'src/generated.ts', startLine:1, endLine:1, content:'', description:''})`, + `CREATE (:Method {id:'method:health', name:'health', filePath:'src/health.resolver.ts', startLine:4, endLine:4, content:'', description:''})`, + `CREATE (:Method {id:'method:save', name:'save', filePath:'src/health.resolver.ts', startLine:6, endLine:6, content:'', description:''})`, + `CREATE (:Const {id:'const:health-document', name:'HealthDocument', filePath:'src/generated.ts', startLine:0, endLine:0, content:'', description:''})`, ]; withTestLbugDB( @@ -140,6 +142,42 @@ withTestLbugDB( registrySpy.mockRestore(); } }); + + it('binds a decorated arrow-field provider at the wrapper startLine (#3201)', async () => { + providerRoot = path.join(handle.tmpHandle.dbPath, 'arrow-provider-repo'); + await fs.mkdir(path.join(providerRoot, 'src'), { recursive: true }); + await fs.writeFile( + path.join(providerRoot, 'src/health.resolver.ts'), + `import { Query, Mutation, Resolver } from '@nestjs/graphql'; +@Resolver() +class HealthResolver { + @Query() + health() { return 'ok'; } + + @Mutation() + save = async () => true; +}`, + 'utf8', + ); + const providerRepo: RepoHandle = { + id: handle.repoId, + path: 'api', + repoPath: providerRoot, + storagePath: handle.tmpHandle.dbPath, + }; + const execute = (query: string, params: Record = {}) => + executeParameterized(handle.repoId, query, params); + const contracts = await new GraphqlExtractor().extract(execute, providerRoot, providerRepo); + expect(contracts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + contractId: 'graphql::mutation::save', + role: 'provider', + symbolUid: 'method:save', + }), + ]), + ); + }); }, { seed: SEED, poolAdapter: true }, ); diff --git a/gitnexus/test/unit/group/graphql-extractor.test.ts b/gitnexus/test/unit/group/graphql-extractor.test.ts index 5e6d69505..86b0492d4 100644 --- a/gitnexus/test/unit/group/graphql-extractor.test.ts +++ b/gitnexus/test/unit/group/graphql-extractor.test.ts @@ -499,4 +499,408 @@ export const TwoDocument = ${generatedDocument('query', 'Two', ['two'])}; ]); expect(parseSpy).toHaveBeenCalledTimes(1); }); + + it('binds NestJS providers at the 0-based graph startLine (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/health.resolver.ts': `import { Query, Mutation, Resolver } from '@nestjs/graphql'; +@Resolver() +class HealthResolver { + @Query() + health() { return 'ok'; } + + @Mutation() + save = async () => true; +}`, + }); + const graphStartLine: Record = { health: 4, save: 6 }; + const startLines: Array<{ name: string; startLine: number }> = []; + const run: CypherExecutor = async (_query, params = {}) => { + const name = String(params.name); + const startLine = Number(params.startLine); + startLines.push({ name, startLine }); + if (graphStartLine[name] !== startLine) return []; + return [{ uid: `sym:${name}`, name, filePath: 'src/health.resolver.ts' }]; + }; + + const contracts = await new GraphqlExtractor().extract(run, root, repo); + + expect(startLines).toEqual([ + { name: 'health', startLine: 4 }, + { name: 'save', startLine: 6 }, + ]); + expect(contracts.map((contract) => contract.contractId)).toEqual([ + 'graphql::query::health', + 'graphql::mutation::save', + ]); + }); + + it('tries the PascalCased Document name graphql-codegen emits (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/timeline.graphql': `query widgetTimeline { getWidget }`, + 'src/generated.ts': `export const WidgetTimelineDocument = ${generatedDocument( + 'query', + 'widgetTimeline', + ['getWidget'], + )};`, + }); + const lookedUp: string[] = []; + const run: CypherExecutor = async (_query, params = {}) => { + const name = String(params.name ?? ''); + lookedUp.push(name); + return name === 'WidgetTimelineDocument' + ? [ + { + uid: 'const:timeline', + name: 'WidgetTimelineDocument', + filePath: 'src/generated.ts', + }, + ] + : []; + }; + + const contracts = await new GraphqlExtractor().extract(run, root, repo); + + expect(lookedUp).toEqual(['widgetTimelineDocument', 'WidgetTimelineDocument']); + expect(contracts).toEqual([ + expect.objectContaining({ + contractId: 'graphql::query::getWidget', + role: 'consumer', + symbolUid: 'const:timeline', + }), + ]); + }); + + 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': ` +fragment widget on Widget { id } +query GetWidget { getWidget { ...widget } } +`, + 'src/root-spread.graphql': ` +fragment MoreRoots on Query { gadget } +query GetWidgets { ...MoreRoots } +`, + 'src/generated.ts': ` +export const WidgetFragmentDoc = /*#__PURE__*/ \` + fragment widget on Widget { id } + \`; +export const GetWidgetDocument = /*#__PURE__*/ \` + query GetWidget { + getWidget { + ...widget + } +} + \${WidgetFragmentDoc}\`; +export const MoreRootsFragmentDoc = /*#__PURE__*/ \` + fragment MoreRoots on Query { gadget } + \`; +export const GetWidgetsDocument = gql\` + query GetWidgets { ...MoreRoots } + \${MoreRootsFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + GetWidgetDocument: [ + { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, + ], + GetWidgetsDocument: [ + { uid: 'const:widgets', name: 'GetWidgetsDocument', filePath: 'src/generated.ts' }, + ], + }), + root, + repo, + ); + + expect(contracts.map((contract) => [contract.contractId, contract.symbolUid]).sort()).toEqual([ + ['graphql::query::gadget', 'const:widgets'], + ['graphql::query::getWidget', 'const:widget'], + ]); + }); + + it('fails closed for dynamic or cyclic generated interpolations (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/dynamic.graphql': `query Dynamic { dynamic }`, + 'src/cycle.graphql': `query Cycle { cycle }`, + 'src/generated.ts': ` +export const DynamicDocument = \`query Dynamic { dynamic }\${foo.bar}\`; +export const CycleFragmentDoc = \`\${CycleDocument}\`; +export const CycleDocument = \`query Cycle { cycle }\${CycleFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + DynamicDocument: [ + { uid: 'const:dynamic', name: 'DynamicDocument', filePath: 'src/generated.ts' }, + ], + CycleDocument: [ + { uid: 'const:cycle', name: 'CycleDocument', filePath: 'src/generated.ts' }, + ], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('fails closed when sibling ${FragmentDoc} names resolve to two static sources (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/get-widget.graphql': `query GetWidget { getWidget }`, + 'src/generated.ts': ` +{ + const WidgetFragmentDoc = /*#__PURE__*/ \` + fragment widget on Widget { id } + \`; +} +export const WidgetFragmentDoc = /*#__PURE__*/ \` + fragment widget on Widget { name } + \`; +export const GetWidgetDocument = /*#__PURE__*/ \` + query GetWidget { getWidget { ...widget } } + \${WidgetFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + GetWidgetDocument: [ + { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, + ], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('fails closed when a FragmentDoc name has a static and a dynamic declarator (#3201)', async () => { + const { root, repo } = await makeRepo({ + 'src/get-widget.graphql': `query GetWidget { getWidget }`, + 'src/generated.ts': ` +{ + const WidgetFragmentDoc = \`\${foo.bar}\`; +} +export const WidgetFragmentDoc = /*#__PURE__*/ \` + fragment widget on Widget { id } + \`; +export const GetWidgetDocument = /*#__PURE__*/ \` + query GetWidget { getWidget { ...widget } } + \${WidgetFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + GetWidgetDocument: [ + { uid: 'const:widget', name: 'GetWidgetDocument', filePath: 'src/generated.ts' }, + ], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('treats template line continuations as empty while inlining ${FragmentDoc} (#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 = gql\`query Q { q }\\ +\${QFragmentDoc}\`; +`, + }); + + 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 NonOctalDecimalEscapeSequence in interpolated gql templates (#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 = gql\`query Q { q }\\8\${QFragmentDoc}\`; +`, + }); + + const contracts = await new GraphqlExtractor().extract( + executor({ + QDocument: [{ uid: 'const:q', name: 'QDocument', filePath: 'src/generated.ts' }], + }), + root, + repo, + ); + + expect(contracts).toEqual([]); + }); + + it('decodes escape_sequence nodes while inlining ${FragmentDoc} (#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 = 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([ + expect.objectContaining({ + contractId: 'graphql::query::q', + role: 'consumer', + symbolUid: 'const:q', + }), + ]); + }); + + 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([]); + }); + + 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 }`, + '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([]); + }); });