fix(group): harden bridge sync and add cross-repo coverage

This commit is contained in:
ivkond 2026-04-10 22:01:49 +03:00
parent fea656ff61
commit ca81e8748d
11 changed files with 868 additions and 53 deletions

View file

@ -5,6 +5,7 @@ import lbug from '@ladybugdb/core';
import type { LbugValue } from '@ladybugdb/core';
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
export function contractNodeId(
repo: string,
@ -120,6 +121,8 @@ export interface WriteBridgeInput {
export async function writeBridge(groupDir: string, input: WriteBridgeInput): Promise<void> {
await fsp.mkdir(groupDir, { recursive: true });
const contracts = dedupeContracts(input.contracts);
const crossLinks = dedupeCrossLinks(input.crossLinks);
const finalPath = path.join(groupDir, 'bridge.lbug');
const tmpPath = path.join(groupDir, 'bridge.lbug.tmp');
@ -137,7 +140,7 @@ export async function writeBridge(groupDir: string, input: WriteBridgeInput): Pr
await ensureBridgeSchema(handle);
// Insert contracts
for (const c of input.contracts) {
for (const c of contracts) {
const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
await queryBridge(
handle,
@ -216,10 +219,19 @@ export async function writeBridge(groupDir: string, input: WriteBridgeInput): Pr
{ repo, role, filePath, symbolName },
);
if (refRows.length > 0) return refRows[0].id;
const fileRows = await queryBridge<{ id: string }>(
handle,
`MATCH (c:Contract) WHERE c.repo = $repo AND c.role = $role
AND c.filePath = $filePath
RETURN c.id AS id LIMIT 2`,
{ repo, role, filePath },
);
if (fileRows.length === 1) return fileRows[0].id;
return null;
};
for (const link of input.crossLinks) {
for (const link of crossLinks) {
const fromId = await findContractNode(
link.from.repo,
'consumer',

View file

@ -122,6 +122,25 @@ function computePhase1Timeout(timeout: number): number {
return Math.min(Math.ceil(timeout * 0.3), 10000);
}
async function runPhase1WithTimeout(
timeout: number,
localImpactFn: (target: string, direction: string) => Promise<unknown>,
target: string,
direction: 'upstream' | 'downstream',
): Promise<{ ok: true; v: unknown } | { ok: false }> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
localImpactFn(target, direction).then((v) => ({ ok: true as const, v })),
new Promise<{ ok: false }>((resolve) => {
timer = setTimeout(() => resolve({ ok: false }), timeout);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function runGroupImpactLegacy(
opts: LegacyGroupImpactOptions,
): Promise<GroupImpactResult> {
@ -133,12 +152,12 @@ export async function runGroupImpactLegacy(
const wallDeadline = tStart + timeout;
const phase1Timeout = computePhase1Timeout(timeout);
const localResult = await Promise.race([
opts.localImpactFn(opts.target, opts.direction).then((v) => ({ ok: true as const, v })),
new Promise<{ ok: false }>((resolve) =>
setTimeout(() => resolve({ ok: false }), phase1Timeout),
),
]);
const localResult = await runPhase1WithTimeout(
phase1Timeout,
opts.localImpactFn,
opts.target,
opts.direction,
);
let truncated = !localResult.ok;
const local = localResult.ok
@ -256,15 +275,6 @@ export async function runGroupImpactLegacy(
/* Cypher-based Phase 2 */
/* ------------------------------------------------------------------ */
const UPSTREAM_QUERY_BASE = `
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
WHERE provider.repo = $sourceRepo
AND (provider.symbolUid IN $localUids
OR (NOT provider.symbolUid IN $localUids AND (provider.filePath + '::' + provider.symbolName) IN $localRefs))
AND l.confidence >= $minConfidence`;
const UPSTREAM_QUERY_SUBGROUP = ` AND (consumer.repo = $subgroup OR consumer.repo STARTS WITH $subgroup + '/')`;
const UPSTREAM_QUERY_RETURN = `
RETURN consumer.repo AS fanOutRepo, consumer.symbolUid AS fanOutUid,
consumer.filePath AS fanOutFilePath, consumer.symbolName AS fanOutSymbolName,
@ -275,15 +285,6 @@ RETURN consumer.repo AS fanOutRepo, consumer.symbolUid AS fanOutUid,
consumer.type AS contractType
ORDER BY l.confidence DESC`;
const DOWNSTREAM_QUERY_BASE = `
MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)
WHERE consumer.repo = $sourceRepo
AND (consumer.symbolUid IN $localUids
OR (NOT consumer.symbolUid IN $localUids AND (consumer.filePath + '::' + consumer.symbolName) IN $localRefs))
AND l.confidence >= $minConfidence`;
const DOWNSTREAM_QUERY_SUBGROUP = ` AND (provider.repo = $subgroup OR provider.repo STARTS WITH $subgroup + '/')`;
const DOWNSTREAM_QUERY_RETURN = `
RETURN provider.repo AS fanOutRepo, provider.symbolUid AS fanOutUid,
provider.filePath AS fanOutFilePath, provider.symbolName AS fanOutSymbolName,
@ -294,6 +295,45 @@ RETURN provider.repo AS fanOutRepo, provider.symbolUid AS fanOutUid,
consumer.type AS contractType
ORDER BY l.confidence DESC`;
function buildBridgeQuery(
direction: 'upstream' | 'downstream',
hasUids: boolean,
hasRefs: boolean,
subgroup?: string,
): string | null {
const isUpstream = direction === 'upstream';
const sourceAlias = isUpstream ? 'provider' : 'consumer';
const fanOutAlias = isUpstream ? 'consumer' : 'provider';
const localMatchers: string[] = [];
if (hasUids) {
localMatchers.push(`${sourceAlias}.symbolUid IN $localUids`);
}
if (hasRefs) {
localMatchers.push(
`(${sourceAlias}.filePath + '::' + ${sourceAlias}.symbolName) IN $localRefs`,
);
}
if (localMatchers.length === 0) {
return null;
}
const whereClauses = [
`${sourceAlias}.repo = $sourceRepo`,
`(${localMatchers.join(' OR ')})`,
'l.confidence >= $minConfidence',
];
const normalizedSubgroup = subgroup?.trim().replace(/\/+$/, '');
if (normalizedSubgroup) {
whereClauses.push(
`(${fanOutAlias}.repo = $subgroup OR ${fanOutAlias}.repo STARTS WITH $subgroup + '/')`,
);
}
const returnClause = isUpstream ? UPSTREAM_QUERY_RETURN : DOWNSTREAM_QUERY_RETURN;
return `MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract)\nWHERE ${whereClauses.join('\n AND ')}${returnClause}`;
}
interface CrossImpactRow {
fanOutRepo: string;
fanOutUid: string;
@ -317,12 +357,12 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImp
const wallDeadline = tStart + timeout;
const phase1Timeout = computePhase1Timeout(timeout);
const localResult = await Promise.race([
opts.localImpactFn(opts.target, opts.direction).then((v) => ({ ok: true as const, v })),
new Promise<{ ok: false }>((resolve) =>
setTimeout(() => resolve({ ok: false }), phase1Timeout),
),
]);
const localResult = await runPhase1WithTimeout(
phase1Timeout,
opts.localImpactFn,
opts.target,
opts.direction,
);
let truncated = !localResult.ok;
const local = localResult.ok
@ -346,27 +386,29 @@ export async function runGroupImpact(opts: GroupImpactOptions): Promise<GroupImp
/* Phase 2 — Cypher bridge query */
const normalizedSubgroup = opts.subgroup?.trim().replace(/\/+$/, '') || null;
const isUpstream = opts.direction === 'upstream';
const queryBase = isUpstream ? UPSTREAM_QUERY_BASE : DOWNSTREAM_QUERY_BASE;
const querySubgroup = isUpstream ? UPSTREAM_QUERY_SUBGROUP : DOWNSTREAM_QUERY_SUBGROUP;
const queryReturn = isUpstream ? UPSTREAM_QUERY_RETURN : DOWNSTREAM_QUERY_RETURN;
const cypher = normalizedSubgroup
? queryBase + querySubgroup + queryReturn
: queryBase + queryReturn;
const localUids = [...uids];
const localRefs = [...phase1Refs];
const cypher = buildBridgeQuery(
opts.direction,
localUids.length > 0,
localRefs.length > 0,
normalizedSubgroup ?? undefined,
);
const queryParams: Record<string, unknown> = {
sourceRepo: opts.repoPath,
localUids: [...uids],
localRefs: [...phase1Refs],
minConfidence,
};
if (localUids.length > 0) {
queryParams.localUids = localUids;
}
if (localRefs.length > 0) {
queryParams.localRefs = localRefs;
}
if (normalizedSubgroup) {
queryParams.subgroup = normalizedSubgroup;
}
const rows = await opts.bridgeQuery<CrossImpactRow>(cypher, queryParams);
const rows = cypher ? await opts.bridgeQuery<CrossImpactRow>(cypher, queryParams) : [];
let maxCrossConf = 0;
const distinctRepos = new Set<string>();

View file

@ -0,0 +1,105 @@
import type { CrossLink, CrossLinkEndpoint, StoredContract } from './types.js';
function contractKey(contract: StoredContract): string {
return [contract.repo, contract.contractId, contract.role, contract.symbolRef.filePath].join(
'\0',
);
}
function endpointKey(endpoint: CrossLinkEndpoint): string {
return [
endpoint.repo,
endpoint.service ?? '',
endpoint.symbolRef.filePath,
endpoint.symbolRef.name,
].join('\0');
}
function contractRichness(contract: StoredContract): number {
let score = 0;
if (contract.symbolUid) score += 3;
if (contract.symbolRef.filePath) score += 2;
if (contract.symbolRef.name && contract.symbolRef.name !== contract.contractId) score += 2;
if (contract.symbolName && contract.symbolName !== contract.contractId) score += 2;
if (contract.service) score += 1;
if (contract.meta.source !== 'manifest') score += 1;
return score;
}
function mergeContracts(existing: StoredContract, incoming: StoredContract): StoredContract {
const [primary, secondary] =
contractRichness(incoming) > contractRichness(existing)
? [incoming, existing]
: [existing, incoming];
const symbolRefName = primary.symbolRef.name || secondary.symbolRef.name;
return {
...secondary,
...primary,
symbolUid: primary.symbolUid || secondary.symbolUid,
symbolRef: {
filePath: primary.symbolRef.filePath || secondary.symbolRef.filePath,
name: symbolRefName,
},
symbolName: primary.symbolName || secondary.symbolName || symbolRefName,
confidence: Math.max(existing.confidence, incoming.confidence),
service: primary.service ?? secondary.service,
meta: { ...secondary.meta, ...primary.meta },
};
}
function mergeEndpoints(
existing: CrossLinkEndpoint,
incoming: CrossLinkEndpoint,
): CrossLinkEndpoint {
return {
repo: existing.repo,
service: existing.service ?? incoming.service,
symbolUid: existing.symbolUid || incoming.symbolUid,
symbolRef: {
filePath: existing.symbolRef.filePath || incoming.symbolRef.filePath,
name: existing.symbolRef.name || incoming.symbolRef.name,
},
};
}
function crossLinkKey(link: CrossLink): string {
return [
link.type,
link.contractId,
link.matchType,
endpointKey(link.from),
endpointKey(link.to),
].join('\0');
}
export function dedupeContracts(items: StoredContract[]): StoredContract[] {
const deduped = new Map<string, StoredContract>();
for (const contract of items) {
const key = contractKey(contract);
const existing = deduped.get(key);
deduped.set(key, existing ? mergeContracts(existing, contract) : contract);
}
return [...deduped.values()];
}
export function dedupeCrossLinks(items: CrossLink[]): CrossLink[] {
const deduped = new Map<string, CrossLink>();
for (const link of items) {
const key = crossLinkKey(link);
const existing = deduped.get(key);
if (!existing) {
deduped.set(key, link);
continue;
}
const keepIncoming = link.confidence > existing.confidence;
const primary = keepIncoming ? link : existing;
const secondary = keepIncoming ? existing : link;
deduped.set(key, {
...primary,
confidence: Math.max(existing.confidence, link.confidence),
from: mergeEndpoints(primary.from, secondary.from),
to: mergeEndpoints(primary.to, secondary.to),
});
}
return [...deduped.values()];
}

View file

@ -12,6 +12,7 @@ import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
import { writeBridge } from './bridge-db.js';
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
export interface SyncOptions {
extractorOverride?:
@ -162,11 +163,24 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
autoContracts = dedupeContracts(autoContracts);
manifestResult = {
contracts: dedupeContracts(manifestResult.contracts),
crossLinks: dedupeCrossLinks(manifestResult.crossLinks),
};
const providerIndex = buildProviderIndex(autoContracts);
const { matched: exactLinks, unmatched } = runExactMatch(autoContracts, providerIndex);
const { matched: wildcardLinks, remaining } = runWildcardMatch(unmatched, providerIndex);
const crossLinks: CrossLink[] = [...manifestResult.crossLinks, ...exactLinks, ...wildcardLinks];
const allContracts: StoredContract[] = [...manifestResult.contracts, ...autoContracts];
const crossLinks: CrossLink[] = dedupeCrossLinks([
...manifestResult.crossLinks,
...exactLinks,
...wildcardLinks,
]);
const allContracts: StoredContract[] = dedupeContracts([
...manifestResult.contracts,
...autoContracts,
]);
if (opts?.groupDir && !opts.skipWrite) {
await writeBridge(opts.groupDir, {

View file

@ -0,0 +1,29 @@
version: 1
name: cross-repo-fixture
description: "Cross-repo fixture backed by split monorepo services"
repos:
platform/auth: test-monorepo/services/auth
platform/orders: test-monorepo/services/orders
platform/gateway: test-monorepo/services/gateway
links:
- from: platform/orders
to: platform/auth
type: grpc
contract: auth.AuthService/Login
role: consumer
packages: {}
detect:
http: true
grpc: true
topics: false
shared_libs: false
embedding_fallback: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3

View file

@ -1,9 +1,34 @@
/**
* Group impact wiring mocks `localImpactFn` / `crossImpactFn`. E2E with real graphs is a follow-up.
* Group impact integration keeps a cheap mocked wiring test and adds a real
* bridge-backed fixture path through GroupService using indexed fixture repos.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runGroupImpactLegacy } from '../../../src/core/group/cross-impact.js';
import type { ContractRegistry } from '../../../src/core/group/types.js';
import { parseGroupConfig } from '../../../src/core/group/config-parser.js';
import { GroupService } from '../../../src/core/group/service.js';
import { syncGroup } from '../../../src/core/group/sync.js';
import { runFullAnalysis } from '../../../src/core/run-analyze.js';
import type {
ContractRegistry,
RepoHandle,
StoredContract,
} from '../../../src/core/group/types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/group');
const FIXTURE_REPOS = [
['platform/auth', 'test-monorepo/services/auth'],
['platform/orders', 'test-monorepo/services/orders'],
['platform/gateway', 'test-monorepo/services/gateway'],
] as const;
const ANALYZE_CALLBACKS = {
onProgress: () => {},
onLog: () => {},
};
function minimalRegistry(crossLinks: ContractRegistry['crossLinks']): ContractRegistry {
return {
@ -16,6 +41,98 @@ function minimalRegistry(crossLinks: ContractRegistry['crossLinks']): ContractRe
};
}
function withIsolatedHomes<T>(run: (tempRoot: string) => Promise<T>): Promise<T> {
const previous = {
GITNEXUS_HOME: process.env.GITNEXUS_HOME,
USERPROFILE: process.env.USERPROFILE,
HOME: process.env.HOME,
};
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-impact-'));
process.env.GITNEXUS_HOME = path.join(tempRoot, '.gitnexus-home');
process.env.USERPROFILE = tempRoot;
process.env.HOME = tempRoot;
return run(tempRoot).finally(async () => {
if (previous.GITNEXUS_HOME === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previous.GITNEXUS_HOME;
if (previous.USERPROFILE === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = previous.USERPROFILE;
if (previous.HOME === undefined) delete process.env.HOME;
else process.env.HOME = previous.HOME;
});
}
function stageFixtureRepos(tempRoot: string): string {
const fixtureRoot = path.join(tempRoot, 'fixture-root');
for (const [, relPath] of FIXTURE_REPOS) {
const sourcePath = path.join(FIXTURES_DIR, relPath);
const targetPath = path.join(fixtureRoot, relPath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.cpSync(sourcePath, targetPath, { recursive: true });
fs.rmSync(path.join(targetPath, '.gitnexus'), { recursive: true, force: true });
}
return fixtureRoot;
}
async function analyzeStagedFixtureRepos(fixtureRoot: string): Promise<void> {
for (const [, relPath] of FIXTURE_REPOS) {
const repoPath = path.join(fixtureRoot, relPath);
await runFullAnalysis(
repoPath,
{
force: true,
embeddings: false,
skipAgentsMd: true,
},
ANALYZE_CALLBACKS,
);
}
}
function makeRepoHandle(
fixtureRoot: string,
groupPath: (typeof FIXTURE_REPOS)[number][0],
registryName: string,
fixtureRelPath = registryName,
): RepoHandle {
const repoPath = path.join(fixtureRoot, fixtureRelPath);
return {
id: registryName,
path: groupPath,
repoPath,
storagePath: path.join(repoPath, '.gitnexus'),
};
}
function makeMinimalImpact(result: { id: string; name: string; filePath: string; type?: string }): {
target: { id: string; name: string; filePath: string; type: string };
direction: 'upstream' | 'downstream';
impactedCount: number;
risk: 'LOW';
summary: { direct: number; processes_affected: number; modules_affected: number };
affected_processes: [];
affected_modules: [];
byDepth: { '1': Array<{ id: string; name: string; filePath: string }> };
} {
return {
target: {
id: result.id,
name: result.name,
filePath: result.filePath,
type: result.type ?? 'Function',
},
direction: 'upstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {
'1': [{ id: result.id, name: result.name, filePath: result.filePath }],
},
};
}
describe('Group impact integration', () => {
it('runs phase 1 and fan-out when cross-link matches UID', async () => {
const registry = minimalRegistry([
@ -72,4 +189,182 @@ describe('Group impact integration', () => {
expect(fanOutCalls).toBe(1);
expect(result.summary.cross_repo_hits).toBe(1);
});
it('runs real cross-repo impact through bridge and indexed fixture repos', async () => {
await withIsolatedHomes(async () => {
const groupHome = process.env.GITNEXUS_HOME!;
const groupDir = path.join(groupHome, 'groups', 'cross-repo-fixture');
const fixtureRoot = stageFixtureRepos(groupHome);
const groupYaml = `version: 1
name: cross-repo-fixture
description: "Cross-repo fixture backed by split monorepo services"
repos:
platform/auth: auth
platform/orders: orders
platform/gateway: gateway
links:
- from: platform/orders
to: platform/auth
type: grpc
contract: "auth.AuthService/Login"
role: consumer
packages: {}
detect:
http: true
grpc: false
topics: false
shared_libs: false
embedding_fallback: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(path.join(groupDir, 'group.yaml'), groupYaml);
await analyzeStagedFixtureRepos(fixtureRoot);
const config = parseGroupConfig(groupYaml);
const handles = new Map(
FIXTURE_REPOS.map(([groupPath, relPath]) => [
path.basename(relPath),
makeRepoHandle(fixtureRoot, groupPath, path.basename(relPath), relPath),
]),
);
const syncResult = await syncGroup(config, {
groupDir,
resolveRepoHandle: async (registryName, groupPath) => {
const fixtureRelPath = FIXTURE_REPOS.find(
([, relPath]) => path.basename(relPath) === registryName,
)?.[1];
if (!fixtureRelPath) return null;
return makeRepoHandle(
fixtureRoot,
groupPath as (typeof FIXTURE_REPOS)[number][0],
registryName,
fixtureRelPath,
);
},
});
expect(syncResult.crossLinks.length).toBeGreaterThan(0);
const bootstrapService = new GroupService({
resolveRepo: async (repoParam?: string) => {
const handle = handles.get(repoParam ?? '');
if (!handle) throw new Error(`Unknown repo: ${repoParam ?? ''}`);
return {
id: handle.id,
name: repoParam ?? handle.id,
repoPath: handle.repoPath,
storagePath: handle.storagePath,
};
},
impact: async () => ({ error: 'bootstrap-only' }),
query: async () => ({ processes: [] }),
impactByUid: async () => null,
});
const bootstrapContracts = (await bootstrapService.groupContracts({
name: 'cross-repo-fixture',
})) as { contracts?: StoredContract[] };
const contractRows = bootstrapContracts.contracts ?? [];
const authProvider = contractRows.find(
(contract) =>
contract.repo === 'platform/auth' &&
contract.role === 'provider' &&
contract.contractId === 'grpc::auth.AuthService/Login',
);
const ordersConsumer = contractRows.find(
(contract) =>
contract.repo === 'platform/orders' &&
contract.role === 'consumer' &&
contract.contractId === 'grpc::auth.AuthService/Login',
);
expect(authProvider).toBeDefined();
expect(ordersConsumer).toBeDefined();
const groupService = new GroupService({
resolveRepo: async (repoParam?: string) => {
const handle = handles.get(repoParam ?? '');
if (!handle) throw new Error(`Unknown repo: ${repoParam ?? ''}`);
return {
id: handle.id,
name: repoParam ?? handle.id,
repoPath: handle.repoPath,
storagePath: handle.storagePath,
};
},
impact: async (_repo, params) => {
const contract = contractRows.find(
(row) =>
row.repo === 'platform/auth' &&
row.role === 'provider' &&
row.symbolName === params.target,
);
if (!contract) {
return { error: `Target "${params.target}" not found in bridge contracts` };
}
return {
...makeMinimalImpact({
id: contract.symbolUid || `${contract.repo}::${contract.contractId}`,
name: contract.symbolName,
filePath: contract.symbolRef.filePath,
}),
direction: params.direction,
};
},
query: async () => ({ processes: [] }),
impactByUid: async (repoId, uid, direction) => {
const targetRepo = handles.get(repoId)?.path;
const contract = contractRows.find(
(row) =>
row.repo === targetRepo &&
(row.symbolUid === uid ||
(row.repo === ordersConsumer?.repo &&
row.contractId === ordersConsumer?.contractId &&
row.symbolName === ordersConsumer?.symbolName)),
);
if (!contract) return null;
return {
...makeMinimalImpact({
id: contract.symbolUid || `${contract.repo}::${contract.contractId}`,
name: contract.symbolName,
filePath: contract.symbolRef.filePath,
}),
direction,
};
},
});
const result = (await groupService.groupImpact({
name: 'cross-repo-fixture',
repo: 'platform/auth',
target: authProvider?.symbolName ?? 'AuthService.Login',
direction: 'upstream',
})) as {
error?: string;
cross?: Array<{ repo_path: string; contract: { id: string } }>;
summary?: { cross_repo_hits: number };
};
expect(result.error).toBeUndefined();
expect(result.summary?.cross_repo_hits).toBeGreaterThan(0);
expect(
result.cross?.some(
(hit) =>
hit.repo_path === 'platform/orders' &&
hit.contract.id === 'grpc::auth.AuthService/Login',
),
).toBe(true);
});
}, 120000);
});

View file

@ -1,17 +1,101 @@
/**
* Group sync integration uses `extractorOverride` / parsed YAML only (no LadybugDB).
* Full pipeline with indexed fixture repos is a follow-up (needs `.gitnexus/lbug`).
* Group sync integration keeps a cheap mocked orchestration check and adds
* a real indexed-fixture path for bridge generation.
*/
import { describe, it, expect } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
import { parseGroupConfig } from '../../../src/core/group/config-parser.js';
import { syncGroup } from '../../../src/core/group/sync.js';
import type { StoredContract } from '../../../src/core/group/types.js';
import { runFullAnalysis } from '../../../src/core/run-analyze.js';
import { bridgeExists } from '../../../src/core/group/bridge-db.js';
import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/group');
const CROSS_REPO_FIXTURE = path.join(FIXTURES_DIR, 'group-cross-repo.yaml');
const FIXTURE_REPOS = [
'test-monorepo/services/auth',
'test-monorepo/services/orders',
'test-monorepo/services/gateway',
] as const;
const ANALYZE_CALLBACKS = {
onProgress: () => {},
onLog: () => {},
};
function withIsolatedHomes<T>(run: (tempRoot: string) => Promise<T>): Promise<T> {
const previous = {
GITNEXUS_HOME: process.env.GITNEXUS_HOME,
USERPROFILE: process.env.USERPROFILE,
HOME: process.env.HOME,
};
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-sync-'));
process.env.GITNEXUS_HOME = path.join(tempRoot, '.gitnexus-home');
process.env.USERPROFILE = tempRoot;
process.env.HOME = tempRoot;
return run(tempRoot).finally(async () => {
if (previous.GITNEXUS_HOME === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = previous.GITNEXUS_HOME;
if (previous.USERPROFILE === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = previous.USERPROFILE;
if (previous.HOME === undefined) delete process.env.HOME;
else process.env.HOME = previous.HOME;
await removeTree(tempRoot);
});
}
function stageFixtureRepos(tempRoot: string): string {
const fixtureRoot = path.join(tempRoot, 'fixture-root');
for (const relPath of FIXTURE_REPOS) {
const sourcePath = path.join(FIXTURES_DIR, relPath);
const targetPath = path.join(fixtureRoot, relPath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.cpSync(sourcePath, targetPath, { recursive: true });
fs.rmSync(path.join(targetPath, '.gitnexus'), { recursive: true, force: true });
}
return fixtureRoot;
}
async function analyzeFixtureRepos(fixtureRoot: string): Promise<void> {
for (const relPath of FIXTURE_REPOS) {
const repoPath = path.join(fixtureRoot, relPath);
await runFullAnalysis(
repoPath,
{
force: true,
embeddings: false,
skipAgentsMd: true,
},
ANALYZE_CALLBACKS,
);
}
}
async function removeTree(targetPath: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt++) {
try {
fs.rmSync(targetPath, { recursive: true, force: true });
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
}
}
}
function resolveFixtureRepoHandle(registryName: string, groupPath: string): RepoHandle {
const repoPath = path.join(FIXTURES_DIR, registryName);
return {
id: groupPath.replace(/[^a-z0-9]+/gi, '-').toLowerCase(),
path: groupPath,
repoPath,
storagePath: path.join(repoPath, '.gitnexus'),
};
}
describe('Group sync integration', () => {
it('parses fixture group.yaml', () => {
@ -76,4 +160,44 @@ describe('Group sync integration', () => {
const healthUnmatched = result.unmatched.some((c) => c.contractId.includes('/api/health'));
expect(healthUnmatched).toBe(true);
});
it('builds bridge.lbug from real per-repo indexes for cross-repo fixture', async () => {
await withIsolatedHomes(async (tempRoot) => {
const config = parseGroupConfig(fs.readFileSync(CROSS_REPO_FIXTURE, 'utf-8'));
const groupDir = path.join(tempRoot, 'group-output');
const fixtureRoot = stageFixtureRepos(tempRoot);
fs.mkdirSync(groupDir, { recursive: true });
await analyzeFixtureRepos(fixtureRoot);
const result = await syncGroup(config, {
groupDir,
resolveRepoHandle: async (registryName, groupPath) => ({
...resolveFixtureRepoHandle(registryName, groupPath),
repoPath: path.join(fixtureRoot, registryName),
storagePath: path.join(fixtureRoot, registryName, '.gitnexus'),
}),
});
const grpcLink = result.crossLinks.find(
(link) =>
link.type === 'grpc' &&
link.matchType === 'wildcard' &&
link.from.repo === 'platform/orders' &&
link.to.repo === 'platform/auth',
);
expect(grpcLink).toBeDefined();
const httpLink = result.crossLinks.find(
(link) =>
link.type === 'http' &&
link.matchType === 'exact' &&
link.contractId === 'http::POST::/api/orders' &&
link.from.repo === 'platform/gateway' &&
link.to.repo === 'platform/orders',
);
expect(httpLink).toBeDefined();
expect(await bridgeExists(groupDir)).toBe(true);
});
}, 120000);
});

View file

@ -15,7 +15,11 @@ import {
detectServiceBoundaries,
assignService,
} from '../../../src/core/group/service-boundary-detector.js';
import { runExactMatch } from '../../../src/core/group/matching.js';
import {
buildProviderIndex,
runExactMatch,
runWildcardMatch,
} from '../../../src/core/group/matching.js';
import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -117,4 +121,51 @@ describe('Monorepo sync integration', () => {
// Summary: we should have at least 2 cross-links
expect(matched.length).toBeGreaterThanOrEqual(2);
});
it('matches wildcard gRPC consumers to method providers with degraded confidence', async () => {
const handle = makeHandle();
const boundaries = await detectServiceBoundaries(MONOREPO_DIR);
const grpcContracts = await new GrpcExtractor().extract(null, MONOREPO_DIR, handle);
const allContracts: StoredContract[] = grpcContracts.map((contract) => ({
...contract,
repo: REPO_GROUP_PATH,
service: assignService(contract.symbolRef.filePath, boundaries),
}));
const providerIndex = buildProviderIndex(allContracts);
const { unmatched } = runExactMatch(allContracts, providerIndex);
const { matched } = runWildcardMatch(unmatched, providerIndex);
const methodProvider = allContracts.find(
(contract) =>
contract.role === 'provider' &&
contract.contractId === 'grpc::auth.AuthService/Login' &&
contract.symbolRef.filePath.includes('services/auth/'),
);
expect(methodProvider).toBeDefined();
const wildcardLink = matched.find(
(link) =>
link.matchType === 'wildcard' &&
link.type === 'grpc' &&
link.contractId.endsWith('/*') &&
link.to.symbolRef.filePath.includes('services/auth/') &&
link.to.symbolRef.name === methodProvider?.symbolRef.name,
);
expect(wildcardLink).toBeDefined();
const wildcardConsumer = allContracts.find(
(contract) =>
contract.role === 'consumer' &&
contract.contractId === wildcardLink?.contractId &&
contract.symbolRef.filePath === wildcardLink?.from.symbolRef.filePath,
);
expect(wildcardConsumer).toBeDefined();
expect(wildcardLink?.confidence).toBe(
Math.min(wildcardConsumer?.confidence ?? 1, methodProvider?.confidence ?? 1),
);
expect(wildcardLink?.confidence).toBeLessThan(methodProvider?.confidence ?? 1);
});
});

View file

@ -230,6 +230,78 @@ describe('writeBridge + read', () => {
await closeBridgeDb(handle!);
});
it('test_writeBridge_duplicate_contracts_and_links_are_deduped', async () => {
const provider = makeContract({
repo: 'backend',
role: 'provider',
symbolUid: '',
symbolName: 'auth.AuthService/Login',
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
contractId: 'grpc::auth.AuthService/Login',
type: 'grpc',
meta: { source: 'manifest' },
});
const concreteProvider = makeContract({
...provider,
symbolUid: 'uid-auth-login',
symbolName: 'Login',
confidence: 0.85,
meta: { source: 'analyze' },
});
const consumer = makeContract({
repo: 'frontend',
role: 'consumer',
symbolUid: '',
symbolName: 'auth.AuthService/Login',
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
contractId: 'grpc::auth.AuthService/Login',
type: 'grpc',
meta: { source: 'manifest' },
});
const link: CrossLink = {
from: {
repo: 'frontend',
symbolUid: '',
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
},
to: {
repo: 'backend',
symbolUid: '',
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
},
type: 'grpc',
contractId: 'grpc::auth.AuthService/Login',
matchType: 'manifest',
confidence: 1,
};
await writeBridge(tmpDir, {
contracts: [provider, concreteProvider, consumer],
crossLinks: [link, { ...link }],
repoSnapshots: {},
missingRepos: [],
});
const handle = await openBridgeDbReadOnly(tmpDir);
const contracts = await queryBridge<{ repo: string; symbolUid: string; symbolName: string }>(
handle!,
'MATCH (c:Contract) RETURN c.repo AS repo, c.symbolUid AS symbolUid, c.symbolName AS symbolName ORDER BY c.repo',
);
const links = await queryBridge<{ fromRepo: string; toRepo: string }>(
handle!,
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo',
);
expect(contracts).toHaveLength(2);
expect(contracts[0]).toEqual({
repo: 'backend',
symbolUid: 'uid-auth-login',
symbolName: 'Login',
});
expect(links).toHaveLength(1);
await closeBridgeDb(handle!);
});
it('test_openBridgeDbReadOnly_returns_null_for_missing', async () => {
const handle = await openBridgeDbReadOnly(path.join(tmpDir, 'nonexistent'));
expect(handle).toBeNull();

View file

@ -294,6 +294,55 @@ describe('runGroupImpact (Cypher-based)', () => {
expect(result.outOfScope).toHaveLength(0);
});
it('test_runGroupImpact_refs_only_bridge_query_omits_empty_uid_clause', async () => {
const bridgeQuery = makeBridgeQuery([
{
fanOutRepo: 'app/backend',
fanOutUid: '',
fanOutFilePath: 'src/ctrl.ts',
fanOutSymbolName: 'UserController.list',
matchedLocalUid: '',
matchedLocalFilePath: 'src/api.ts',
matchedLocalSymbolName: 'fetchUsers',
matchType: 'exact',
confidence: 1,
contractId: 'http::GET::/api/users',
contractType: 'http',
},
]);
const crossImpactFn = vi.fn().mockResolvedValue({
byDepth: {},
affected_processes: [],
});
const result = await runGroupImpact({
groupName: 'test',
target: 'fetchUsers',
repoPath: 'app/frontend',
direction: 'downstream',
bridgeQuery,
localImpactFn: async () => ({
target: { id: '', name: 'fetchUsers', filePath: 'src/api.ts' },
direction: 'downstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
affected_processes: [],
affected_modules: [],
byDepth: {},
}),
crossImpactFn,
});
expect(bridgeQuery).toHaveBeenCalledOnce();
const [cypher, params] = bridgeQuery.mock.calls[0];
expect(cypher).toContain("(consumer.filePath + '::' + consumer.symbolName) IN $localRefs");
expect(cypher).not.toContain('consumer.symbolUid IN $localUids');
expect(params.localRefs).toEqual(['src/api.ts::fetchUsers']);
expect(params.localUids).toBeUndefined();
expect(result.summary.cross_repo_hits).toBe(1);
});
it('test_runGroupImpact_downstream_fans_out_to_provider', async () => {
const bridgeQuery = makeBridgeQuery([
{

View file

@ -114,6 +114,28 @@ describe('syncGroup', () => {
expect(result.crossLinks[0].to.service).toBe('services/auth');
});
it('deduplicates duplicate contracts and links before returning', async () => {
const config = makeConfig({ 'app/backend': 'backend-repo', 'app/frontend': 'frontend-repo' });
const duplicateProvider = makeContract('http::GET::/api/users', 'provider', 'app/backend');
const duplicateConsumer = makeContract('http::GET::/api/users', 'consumer', 'app/frontend');
const result = await syncGroup(config, {
extractorOverride: async () => [
duplicateProvider,
{ ...duplicateProvider, confidence: 0.9, meta: { source: 'manifest' } },
duplicateConsumer,
{ ...duplicateConsumer, confidence: 0.75, meta: { source: 'manifest' } },
],
skipWrite: true,
});
expect(result.contracts).toHaveLength(2);
expect(result.crossLinks).toHaveLength(1);
expect(result.contracts.find((contract) => contract.role === 'provider')?.confidence).toBe(0.9);
expect(result.contracts.find((contract) => contract.role === 'consumer')?.confidence).toBe(0.8);
});
function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract {
return {
contractId: id,