fix(group): extract NestJS GraphQL contracts on real indexes (#3201) (#3227)

* 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:
Gergő Magyar 2026-09-09 17:30:00 +01:00 committed by GitHub
parent 20b13b3ed6
commit b60c21d05d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 608 additions and 14 deletions

View file

@ -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;

View file

@ -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 },
);

View file

@ -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([]);
});
});