mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-10 22:43:40 +00:00
fix(group): harden bridge sync and add cross-repo coverage
This commit is contained in:
parent
fea656ff61
commit
ca81e8748d
11 changed files with 868 additions and 53 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
105
gitnexus/src/core/group/normalization.ts
Normal file
105
gitnexus/src/core/group/normalization.ts
Normal 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()];
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
29
gitnexus/test/fixtures/group/group-cross-repo.yaml
vendored
Normal file
29
gitnexus/test/fixtures/group/group-cross-repo.yaml
vendored
Normal 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
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue