GitNexus/gitnexus/test/integration/group/graphql-resolve-symbol.test.ts
Gergő Magyar b60c21d05d
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>
2026-09-09 17:30:00 +01:00

183 lines
6.4 KiB
TypeScript

import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { afterAll, expect, it, vi } from 'vitest';
import { GraphqlExtractor } from '../../../src/core/group/extractors/graphql-extractor.js';
import { syncGroup } from '../../../src/core/group/sync.js';
import { closeLbug, executeParameterized } from '../../../src/core/lbug/pool-adapter.js';
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: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(
'graphql-resolve-symbol',
(handle) => {
let providerRoot = '';
let consumerRoot = '';
afterAll(async () => {
try {
await closeLbug(handle.repoId);
} catch {
/* best-effort */
}
});
it('anchors provider and generated Document consumer through real LadybugDB queries', async () => {
providerRoot = path.join(handle.tmpHandle.dbPath, 'provider-repo');
consumerRoot = path.join(handle.tmpHandle.dbPath, 'consumer-repo');
await fs.mkdir(path.join(providerRoot, 'src'), { recursive: true });
await fs.mkdir(path.join(consumerRoot, 'src'), { recursive: true });
await fs.writeFile(
path.join(providerRoot, 'src/health.resolver.ts'),
`import { Query, Resolver } from '@nestjs/graphql';\n@Resolver()\nclass HealthResolver {\n @Query()\n health() { return 'ok'; }\n}`,
'utf8',
);
await fs.writeFile(
path.join(consumerRoot, 'src/health.graphql'),
'query Health { health }',
'utf8',
);
await fs.writeFile(
path.join(consumerRoot, 'src/generated.ts'),
`export const HealthDocument = {
kind: 'Document',
definitions: [{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Health' },
selectionSet: {
kind: 'SelectionSet',
selections: [{ kind: 'Field', name: { kind: 'Name', value: 'health' } }]
}
}]
};`,
'utf8',
);
const providerRepo: RepoHandle = {
id: handle.repoId,
path: 'api',
repoPath: providerRoot,
storagePath: handle.tmpHandle.dbPath,
};
const consumerRepo: RepoHandle = {
id: handle.repoId,
path: 'web',
repoPath: consumerRoot,
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)),
...(await new GraphqlExtractor().extract(execute, consumerRoot, consumerRepo)),
];
expect(contracts).toEqual(
expect.arrayContaining([
expect.objectContaining({
contractId: 'graphql::query::health',
role: 'provider',
symbolUid: 'method:health',
}),
expect.objectContaining({
contractId: 'graphql::query::health',
role: 'consumer',
symbolUid: 'const:health-document',
}),
]),
);
const repoManager = await import('../../../src/storage/repo-manager.js');
const registrySpy = vi.spyOn(repoManager, 'readRegistryStrict').mockResolvedValue([]);
const config: GroupConfig = {
version: 1,
name: 'graphql-production-wiring',
description: '',
repos: { api: 'api', web: 'web' },
links: [],
packages: {},
detect: {
http: false,
graphql: true,
grpc: false,
thrift: false,
topics: false,
includes: false,
workspace_deps: false,
},
matching: {},
};
try {
const synced = await syncGroup(config, {
resolveRepoHandle: async (_regName, groupPath) =>
groupPath === 'api' ? providerRepo : consumerRepo,
skipWrite: true,
});
expect(
synced.contracts.map((contract) => [
contract.contractId,
contract.role,
contract.symbolUid,
]),
).toEqual([
['graphql::query::health', 'provider', 'method:health'],
['graphql::query::health', 'consumer', 'const:health-document'],
]);
expect(synced.crossLinks).toEqual([
expect.objectContaining({
contractId: 'graphql::query::health',
matchType: 'exact',
from: expect.objectContaining({ repo: 'web', symbolUid: 'const:health-document' }),
to: expect.objectContaining({ repo: 'api', symbolUid: 'method:health' }),
}),
]);
} finally {
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 },
);