mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-10 22:43:40 +00:00
* fix(group): extract NestJS GraphQL contracts against real 0-based indexes (#3201) Provider lookup used 1-based startLine while the graph stores tree-sitter rows, so every resolver missed. Also try PascalCased Document names and inline sibling FragmentDoc interpolations from graphql-codegen output. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(group): bind arrow-field providers and fail closed on interpolations Match Method startLine to the public_field_definition wrapper, decode template escape sequences, and reject FragmentDoc names that mix static and dynamic declarators. Co-authored-by: Cursor <cursoragent@cursor.com> * 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> * 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> * 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 <cursoragent@cursor.com> * fix(group): fail closed on invalid tagged-template escapes Treat line continuations as empty cooked text and reject \8/\9 plus legacy octals so reconstructed gql source matches runtime. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
20b13b3ed6
commit
b60c21d05d
3 changed files with 608 additions and 14 deletions
|
|
@ -92,6 +92,134 @@ function unquote(text: string): string | null {
|
|||
return value.includes('${') ? null : value;
|
||||
}
|
||||
|
||||
const SIMPLE_TEMPLATE_ESCAPES: Record<string, string> = {
|
||||
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<string, string | null>;
|
||||
|
||||
function uniqueStaticSource(
|
||||
name: string,
|
||||
declarators: GeneratedSymbolIndex,
|
||||
resolving: Set<string>,
|
||||
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<string>();
|
||||
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<string>,
|
||||
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<string> = 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;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) =>
|
||||
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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string, number> = { 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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue