mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
* feat(group): bridge.lbug storage + contract matching expansion Part 1 of 4 in the split of #606 (ticket: #791, closes #790 with a revised plan per @magyargergo's request). ## What changed Adds the LadybugDB-backed bridge storage infrastructure and extends the contract matching algorithm with wildcard support. All changes are additive: storage.ts, sync.ts, service.ts, cli/group.ts, mcp/tools.ts are left on their upstream main versions and will migrate to the new bridge in follow-up PRs (#792, #793, #794). ### Files **New (844 LOC prod):** - `gitnexus/src/core/group/bridge-db.ts` — atomic write-to-temp with `retryRename` for Windows EBUSY/EPERM, per-item write tolerance via `WriteBridgeReport`, `findContractNode` with three-tier symbol lookup (uid → filePath+name → filePath) - `gitnexus/src/core/group/bridge-schema.ts` — schema DDL - `gitnexus/src/core/group/normalization.ts` — contract ID canonicalization + `dedupeContracts` / `dedupeCrossLinks` helpers used by both matching and bridge write **Modified (+137 LOC prod):** - `gitnexus/src/core/group/matching.ts` — adds `runWildcardMatch` for `grpc::Service/*` wildcard consumers, `buildProviderIndex` helper, and canonical gRPC ID handling in `normalizeContractId` - `gitnexus/src/core/group/types.ts` — `MatchType` gains `'wildcard'`; new `BridgeHandle` and `BridgeMeta` interfaces **New tests (658 LOC):** - `gitnexus/test/unit/group/bridge-db.test.ts` — core write/read round trip, `WriteBridgeReport` shape, dropped-links counter, retryRename behavior on EBUSY/ENOENT/EPERM/EACCES - `gitnexus/test/unit/group/bridge-db-edge.test.ts` — edge cases (malformed meta, missing contract nodes, concurrent access) **Modified tests (+225 LOC):** - `gitnexus/test/unit/group/matching.test.ts` — wildcard consumer matching, gRPC canonical ID handling, same-service guard ### Self-review fixes folded in Carried forward from the original #606 self-review: - `writeBridge` try/finally handle lifecycle + `handleClosed` sentinel - `openBridgeDbReadOnly` partial-handle cleanup - `writeBridgeMeta` uses `retryRename` for Windows consistency - `retryRename` unit tests (was zero coverage) - Per-item try/catch around every CREATE loop so one malformed contract doesn't abort the whole write - Dropped cross-link counter (`linksDroppedMissingNode`) ### Why now magyargergo asked for the #606 PR to be split so we can iterate with confidence (https://github.com/abhigyanpatwari/GitNexus/pull/606#issuecomment-4229612271). This is the foundational layer — pure infra, no user-facing surface, no callers of the new APIs in this PR. Later PRs wire it in. ### How to verify - `cd gitnexus && npx tsc --noEmit` - `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/bridge-db-edge.test.ts --pool=forks` - `cd gitnexus && npx vitest run test/unit/group/matching.test.ts --pool=forks` - Pre-commit hook runs clean ### Risk / rollback **Low.** All new code sits under `src/core/group/` in new files plus a minimal `+16/-1` diff to `types.ts` and a `+136/-0` diff to `matching.ts` (both purely additive). No existing callers reference the new APIs (bridge-db, openBridgeOrFallback, runWildcardMatch) — the PRs that wire them in come later in the split chain. Rollback = `git revert` of the merge commit; no state introduced, no schema migration triggered. ### Scope discipline (per GUARDRAILS.md) - Only the 8 files listed above are touched; no drive-by refactors - No CI/release/security config changes - No secrets, tokens, or machine-specific paths - Content is lifted from the #606 branch which already passed CI 11/11 green on `d15b8cb` (before the split) ### Dependencies - **Base:** `main` (no dependencies on other split PRs) - **Blocks:** extractor expansion (#792), sync pipeline (#793), cross-impact feature (#794) - **Related ticket:** #791 Co-authored-by: Claude <noreply@anthropic.com> * fix(group): address @claude review on #795 Addresses the findings from the automated review on PR #795 (https://github.com/abhigyanpatwari/GitNexus/pull/795#issuecomment-4229770000 — posted by @magyargergo / claude-code Action run). ### Medium severity (reviewer flagged as blockers) - **bridge-db.ts `openBridgeDbReadOnly` bak recovery** — the `.bak` recovery path used bare `fsp.rename(bakPath, dbPath)`, which is exactly the scenario most likely to hit Windows EBUSY/EPERM (an interrupted writer still holding the handle for a few ms). Switched to `retryRename` for consistency with the rest of the file's Windows-safe rename path. - **bridge-db.ts `ensureBridgeSchema` error detection** — the inline `msg.includes('already exists')` substring match has been lifted into a named constant `LBUG_ALREADY_EXISTS_MSG` with a comment documenting the coupling to LadybugDB's error message wording and why we can't use `IF NOT EXISTS` (LadybugDB DDL doesn't support it) or typed errors (LadybugDB's JS driver doesn't expose error codes). Also tightened the `catch (err: any)` to `catch (err: unknown)`. - **bridge-db.ts `findContractNode` — extracted out of writeBridge** — the 35-line async closure living inside `writeBridge` has been lifted to three module-level functions: `createContractLookupIndex`, `indexContract`, and `findContractNode`. `findContractNode` is now a pure synchronous function taking a prebuilt index instead of doing its own DB queries. The `writeBridge` cross-link loop is now ~25 lines instead of ~100. - **bridge-db.ts `findContractNode` — N+1 query elimination** — the old inner-closure version issued up to 6 DB round-trips per cross-link (2 endpoints × up to 3 tiers of fallback queries). For a group with 1000 cross-links, that's up to 6000 DB queries just to resolve endpoints. The new version consults an in-memory `ContractLookupIndex` built incrementally as contracts are inserted (`indexContract` called AFTER each successful insert so failed inserts don't poison the index). Cross-link resolution is now O(1) per link instead of O(3) DB queries per link, with zero DB round-trips during the cross-link loop. ### Minor severity - **bridge-db.ts `queryBridge` empty-array guard** — if LadybugDB ever returns an empty `QueryResult[]` at the top level (shouldn't happen with single-statement calls, but driver contract isn't explicit), the old code would call `.getAll()` on `undefined` and crash with a confusing stack. Added an `unwrapQueryResult` helper that throws an explicit `'empty QueryResult array'` error instead, making a potential driver regression visible immediately. - **normalization.ts `contractRichness` weights** — added a block-level comment documenting the weight ordering (+3 for symbolUid, +2 for each symbol-identifying field, +1 for service tag or non-manifest origin) and explicitly noting that the absolute numbers don't matter, only the relative ordering. Matches the "comment for contributors" suggestion in the review. - **bridge-schema.ts `BRIDGE_SCHEMA_VERSION` migration comment** — added a 4-point contract explaining what bumping the constant means ("discard and re-sync" strategy for V1, no in-place migration yet, new migration logic should live in a separate `bridge-migrations.ts` module when it becomes necessary). - **test/unit/group/fixtures.ts** — extracted the `makeContract` helper previously copy-pasted between `bridge-db.test.ts` and `bridge-db-edge.test.ts` into a shared fixtures module. Both test files now import from `./fixtures.js`. Kept the scope minimal: fixtures is NOT a general-purpose factory module, just the shared baseline contract builder. ### New tests Added 9 pure-function unit tests for the now-extracted `findContractNode` in `bridge-db.test.ts`: - returns null on empty index - tier 1 (symbolUid) match, including repo-scope and role-scope isolation - tier 2 (filePath + symbolName) fallback when symbolUid is empty or mismatches - tier 3 (filePath only) when exactly one contract lives in the file, and refusal when multiple do - priority ordering when multiple tiers could resolve These are fully isolated — no DB, no temp directories, no native LadybugDB binding — so they run in <10ms total and are immediately trustworthy as a regression safety net. ### Deliberately deferred (reviewer marked as "fine for now") - `BridgeHandle._db` / `._conn` typing to `unknown` with casts in `bridge-db.ts` — reviewer's note: "The typing is fine for now." - Batch inserts via `UNWIND` — needs LadybugDB support confirmation, tracked as a follow-up; the per-item pattern remains. - `queryBridge` prepared-statement lifecycle — the current pattern (prepare → execute → GC) relies on LadybugDB's internals, worth verifying against their docs in a separate audit. ### Scope discipline (per `GUARDRAILS.md`) - Only files touched by this PR (`bridge-db.ts`, `bridge-schema.ts`, `normalization.ts`, both bridge test files, new `fixtures.ts`) — no drive-by refactors - No CI/release/security config changes - No secrets ### Test + typecheck status - `npx tsc --noEmit` clean - `bridge-db.test.ts`: added 9 `findContractNode` tests, all pass in isolation. The full-file run still hits the pre-existing native LadybugDB cleanup segfault that flakes the reported count — same as every prior commit on this branch, not a regression. - `bridge-db-edge.test.ts`: 4/4 pass - `matching.test.ts`: 28/28 pass - `types.test.ts`: 5/5 pass - `retryRename` tests (4/4) and `findContractNode` tests (9/9) verified in isolation via `-t` filter Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9364739fb4
commit
1ff324ca16
9 changed files with 1919 additions and 15 deletions
588
gitnexus/src/core/group/bridge-db.ts
Normal file
588
gitnexus/src/core/group/bridge-db.ts
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
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,
|
||||
contractId: string,
|
||||
role: string,
|
||||
filePath: string,
|
||||
): string {
|
||||
return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* ContractLookupIndex — in-memory lookup for findContractNode */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* In-memory index of contract node IDs keyed three ways, mirroring the
|
||||
* three-tier fallback lookup in {@link findContractNode}. Built once per
|
||||
* `writeBridge` call after all contracts are successfully inserted, then
|
||||
* consulted for every cross-link — which eliminates the former N+1 query
|
||||
* pattern (up to `6 × cross-links` DB round-trips) and turns cross-link
|
||||
* resolution into constant-time per link.
|
||||
*
|
||||
* Keys are deliberately flat strings (not tuples) so `Map<string, ...>`
|
||||
* works; the separator `\0` can't occur in any legal repo path / file
|
||||
* path / symbol identifier, which makes the encoding injection-safe.
|
||||
*/
|
||||
export interface ContractLookupIndex {
|
||||
/** tier 1: `repo + role + symbolUid` → contract node id */
|
||||
byUid: Map<string, string>;
|
||||
/** tier 2: `repo + role + filePath + symbolName` → contract node id */
|
||||
byRef: Map<string, string>;
|
||||
/** tier 3: `repo + role + filePath` → list of contract node ids in that file */
|
||||
byFile: Map<string, string[]>;
|
||||
}
|
||||
|
||||
export function createContractLookupIndex(): ContractLookupIndex {
|
||||
return {
|
||||
byUid: new Map(),
|
||||
byRef: new Map(),
|
||||
byFile: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function uidKey(repo: string, role: string, symbolUid: string): string {
|
||||
return `${repo}\0${role}\0${symbolUid}`;
|
||||
}
|
||||
|
||||
function refKey(repo: string, role: string, filePath: string, symbolName: string): string {
|
||||
return `${repo}\0${role}\0${filePath}\0${symbolName}`;
|
||||
}
|
||||
|
||||
function fileKey(repo: string, role: string, filePath: string): string {
|
||||
return `${repo}\0${role}\0${filePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a successfully-inserted contract to the lookup index. Must be called
|
||||
* AFTER the DB insert succeeds (not before) so failed inserts don't poison
|
||||
* the index and cause cross-links to point at non-existent rows.
|
||||
*/
|
||||
export function indexContract(
|
||||
index: ContractLookupIndex,
|
||||
contract: StoredContract,
|
||||
nodeId: string,
|
||||
): void {
|
||||
if (contract.symbolUid) {
|
||||
index.byUid.set(uidKey(contract.repo, contract.role, contract.symbolUid), nodeId);
|
||||
}
|
||||
index.byRef.set(
|
||||
refKey(contract.repo, contract.role, contract.symbolRef.filePath, contract.symbolRef.name),
|
||||
nodeId,
|
||||
);
|
||||
const fk = fileKey(contract.repo, contract.role, contract.symbolRef.filePath);
|
||||
const existing = index.byFile.get(fk);
|
||||
if (existing) {
|
||||
existing.push(nodeId);
|
||||
} else {
|
||||
index.byFile.set(fk, [nodeId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a cross-link endpoint (consumer or provider reference) to an
|
||||
* already-inserted contract node id. Returns `null` if no match — the
|
||||
* caller is expected to count that as a dropped link in `WriteBridgeReport`.
|
||||
*
|
||||
* The resolution order matches the pre-cache DB-query behavior:
|
||||
* 1. exact `symbolUid` match in the same `(repo, role)` scope
|
||||
* 2. exact `(filePath, symbolName)` match
|
||||
* 3. if exactly one contract lives in the file → that one (fallback for
|
||||
* legacy graph-assisted extractors that couldn't resolve a symbol name)
|
||||
*
|
||||
* This is a pure function — no I/O, no DB — so it's trivial to unit-test
|
||||
* in isolation (which was the reviewer's main clean-code concern on the
|
||||
* original 35-line inner closure in `writeBridge`).
|
||||
*/
|
||||
export function findContractNode(
|
||||
index: ContractLookupIndex,
|
||||
repo: string,
|
||||
role: 'consumer' | 'provider',
|
||||
symbolUid: string,
|
||||
filePath: string,
|
||||
symbolName: string,
|
||||
): string | null {
|
||||
if (symbolUid) {
|
||||
const uidHit = index.byUid.get(uidKey(repo, role, symbolUid));
|
||||
if (uidHit !== undefined) return uidHit;
|
||||
}
|
||||
|
||||
const refHit = index.byRef.get(refKey(repo, role, filePath, symbolName));
|
||||
if (refHit !== undefined) return refHit;
|
||||
|
||||
const fileCandidates = index.byFile.get(fileKey(repo, role, filePath));
|
||||
if (fileCandidates && fileCandidates.length === 1) return fileCandidates[0];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function openBridgeDb(dbPath: string): Promise<BridgeHandle> {
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fsp.mkdir(parentDir, { recursive: true });
|
||||
const db = new lbug.Database(dbPath, 0, false, false); // writable
|
||||
const conn = new lbug.Connection(db);
|
||||
return { _db: db, _conn: conn, groupDir: parentDir } as BridgeHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* LadybugDB returns an error whose message contains this substring when a
|
||||
* CREATE NODE TABLE or CREATE REL TABLE statement hits an already-existing
|
||||
* table. LadybugDB DDL doesn't support IF NOT EXISTS, and its JS driver
|
||||
* doesn't expose typed error codes, so we match on the message substring —
|
||||
* the same pattern used by `core/lbug/lbug-adapter.ts`. If a future
|
||||
* LadybugDB release changes the wording, update this constant.
|
||||
*/
|
||||
const LBUG_ALREADY_EXISTS_MSG = 'already exists';
|
||||
|
||||
export async function ensureBridgeSchema(handle: BridgeHandle): Promise<void> {
|
||||
const conn = handle._conn as lbug.Connection;
|
||||
for (const q of BRIDGE_SCHEMA_QUERIES) {
|
||||
try {
|
||||
await conn.query(q);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes(LBUG_ALREADY_EXISTS_MSG)) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryBridge<T>(
|
||||
handle: BridgeHandle,
|
||||
cypher: string,
|
||||
params?: Record<string, LbugValue>,
|
||||
): Promise<T[]> {
|
||||
const conn = handle._conn as lbug.Connection;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const stmt = await conn.prepare(cypher);
|
||||
if (!stmt.isSuccess()) {
|
||||
const errMsg = await stmt.getErrorMessage();
|
||||
throw new Error(`Bridge query prepare failed: ${errMsg}`);
|
||||
}
|
||||
const queryResult = await conn.execute(stmt, params);
|
||||
const result = unwrapQueryResult(queryResult);
|
||||
return (await result.getAll()) as T[];
|
||||
}
|
||||
const queryResult = await conn.query(cypher);
|
||||
const result = unwrapQueryResult(queryResult);
|
||||
return (await result.getAll()) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* LadybugDB's `conn.query` / `conn.execute` can return either a single
|
||||
* `QueryResult` (for a single statement) or an array of them (when a
|
||||
* multi-statement script is dispatched). We always pass a single statement,
|
||||
* so the array form is a wrapper we unwrap here — but an empty top-level
|
||||
* array would cause `.getAll()` on `undefined` and crash with a confusing
|
||||
* stack. Throwing an explicit error makes a driver-contract regression
|
||||
* visible immediately instead of masking it.
|
||||
*/
|
||||
function unwrapQueryResult(queryResult: lbug.QueryResult | lbug.QueryResult[]): lbug.QueryResult {
|
||||
if (Array.isArray(queryResult)) {
|
||||
if (queryResult.length === 0) {
|
||||
throw new Error('Bridge query returned an empty QueryResult array');
|
||||
}
|
||||
return queryResult[0];
|
||||
}
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
|
||||
try {
|
||||
await (handle._conn as lbug.Connection).close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await (handle._db as lbug.Database).close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* retryRename — handles transient EBUSY/EPERM/EACCES on Windows */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
||||
|
||||
export async function retryRename(src: string, dst: string, attempts = 3): Promise<void> {
|
||||
for (let i = 1; i <= attempts; i++) {
|
||||
try {
|
||||
await fsp.rename(src, dst);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (!code || !RETRY_CODES.has(code) || i === attempts) throw err;
|
||||
await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i - 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* writeBridgeMeta / readBridgeMeta */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
|
||||
const target = path.join(groupDir, 'meta.json');
|
||||
const tmp = `${target}.tmp.${Date.now()}`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8');
|
||||
// Use retryRename for consistency with writeBridge's atomic swap — on
|
||||
// Windows a concurrent reader can cause EBUSY/EPERM even on a tiny
|
||||
// meta.json, and we don't want meta write to be less robust than the
|
||||
// bridge.lbug swap it accompanies.
|
||||
await retryRename(tmp, target);
|
||||
}
|
||||
|
||||
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
|
||||
try {
|
||||
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
|
||||
return JSON.parse(content) as BridgeMeta;
|
||||
} catch {
|
||||
return { version: 0, generatedAt: '', missingRepos: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* writeBridge — atomic write-to-temp-then-rename */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface WriteBridgeInput {
|
||||
contracts: StoredContract[];
|
||||
crossLinks: CrossLink[];
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
missingRepos: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-fatal issues encountered during writeBridge. Callers can log these to
|
||||
* surface partial-success state without aborting the whole sync.
|
||||
* `sampleErrors` is capped at MAX_SAMPLE_ERRORS per category to bound memory.
|
||||
*/
|
||||
export interface WriteBridgeReport {
|
||||
contractsInserted: number;
|
||||
contractsFailed: number;
|
||||
snapshotsInserted: number;
|
||||
snapshotsFailed: number;
|
||||
linksInserted: number;
|
||||
linksFailed: number;
|
||||
/** Cross-links skipped because their from/to contract nodes weren't found. */
|
||||
linksDroppedMissingNode: number;
|
||||
sampleErrors: Array<{
|
||||
kind: 'contract' | 'snapshot' | 'link';
|
||||
id: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MAX_SAMPLE_ERRORS = 10;
|
||||
|
||||
function errMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
try {
|
||||
return String(err);
|
||||
} catch {
|
||||
return 'unknown error';
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeBridge(
|
||||
groupDir: string,
|
||||
input: WriteBridgeInput,
|
||||
): Promise<WriteBridgeReport> {
|
||||
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');
|
||||
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
|
||||
|
||||
const report: WriteBridgeReport = {
|
||||
contractsInserted: 0,
|
||||
contractsFailed: 0,
|
||||
snapshotsInserted: 0,
|
||||
snapshotsFailed: 0,
|
||||
linksInserted: 0,
|
||||
linksFailed: 0,
|
||||
linksDroppedMissingNode: 0,
|
||||
sampleErrors: [],
|
||||
};
|
||||
|
||||
const recordError = (kind: 'contract' | 'snapshot' | 'link', id: string, err: unknown) => {
|
||||
if (report.sampleErrors.length < MAX_SAMPLE_ERRORS) {
|
||||
report.sampleErrors.push({ kind, id, message: errMessage(err) });
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up any leftover tmp
|
||||
try {
|
||||
await fsp.rm(tmpPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// 1. Create temp DB, insert all data.
|
||||
//
|
||||
// Everything after `openBridgeDb` must run inside a try/finally so that
|
||||
// if ANY step before the explicit `closeBridgeDb` throws — schema
|
||||
// creation, a contract insert loop that rethrows, a snapshot write, the
|
||||
// cross-link loop, or anything else — the handle is still released. A
|
||||
// leaked handle holds the native LadybugDB file lock on tmpPath, which
|
||||
// (a) leaks a FD and (b) prevents the next writeBridge call from
|
||||
// reusing the same tmp slot.
|
||||
const handle = await openBridgeDb(tmpPath);
|
||||
let handleClosed = false;
|
||||
try {
|
||||
await ensureBridgeSchema(handle);
|
||||
|
||||
// Build the lookup index incrementally as contracts are inserted, so
|
||||
// failed inserts are never in the index (and therefore never resolved
|
||||
// by the cross-link loop below). This replaces a previous N+1 query
|
||||
// pattern where each link made up to 6 DB round-trips to find its
|
||||
// endpoints — see ContractLookupIndex.
|
||||
const lookupIndex = createContractLookupIndex();
|
||||
|
||||
// Insert contracts — tolerate individual failures (e.g., a corrupt meta
|
||||
// that can't be serialized). The whole sync must not fail because one
|
||||
// contract is broken.
|
||||
for (const c of contracts) {
|
||||
const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
|
||||
try {
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (n:Contract {
|
||||
id: $id,
|
||||
contractId: $contractId,
|
||||
type: $type,
|
||||
role: $role,
|
||||
repo: $repo,
|
||||
service: $service,
|
||||
symbolUid: $symbolUid,
|
||||
filePath: $filePath,
|
||||
symbolName: $symbolName,
|
||||
confidence: $confidence,
|
||||
meta: $meta
|
||||
})`,
|
||||
{
|
||||
id,
|
||||
contractId: c.contractId,
|
||||
type: c.type,
|
||||
role: c.role,
|
||||
repo: c.repo,
|
||||
service: c.service ?? '',
|
||||
symbolUid: c.symbolUid,
|
||||
filePath: c.symbolRef.filePath,
|
||||
symbolName: c.symbolName,
|
||||
confidence: c.confidence,
|
||||
meta: JSON.stringify(c.meta),
|
||||
},
|
||||
);
|
||||
report.contractsInserted++;
|
||||
// Only index on successful insert — the cross-link loop must never
|
||||
// resolve to a row that isn't actually in the DB.
|
||||
indexContract(lookupIndex, c, id);
|
||||
} catch (err) {
|
||||
report.contractsFailed++;
|
||||
recordError('contract', id, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert repo snapshots
|
||||
for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
|
||||
try {
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (s:RepoSnapshot {
|
||||
id: $id,
|
||||
indexedAt: $indexedAt,
|
||||
lastCommit: $lastCommit
|
||||
})`,
|
||||
{
|
||||
id: repoId,
|
||||
indexedAt: snap.indexedAt,
|
||||
lastCommit: snap.lastCommit,
|
||||
},
|
||||
);
|
||||
report.snapshotsInserted++;
|
||||
} catch (err) {
|
||||
report.snapshotsFailed++;
|
||||
recordError('snapshot', repoId, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert cross-links (tolerating missing nodes).
|
||||
//
|
||||
// `findContractNode` consults the in-memory lookup index built above,
|
||||
// not the DB — that's an O(1) pure-function lookup per endpoint instead
|
||||
// of the previous 2-3 DB queries. For M cross-links, the previous code
|
||||
// issued up to 6M round-trips; this version issues zero.
|
||||
//
|
||||
// `link.contractId` may differ between the consumer and provider sides
|
||||
// (e.g. wildcard consumer `grpc::Service/*` → method-level provider
|
||||
// `grpc::Service/Method`) — that's why we resolve each endpoint
|
||||
// independently via its own `(repo, role, symbolUid, filePath, symbolName)`
|
||||
// tuple rather than matching on contractId.
|
||||
for (const link of crossLinks) {
|
||||
const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`;
|
||||
try {
|
||||
const fromId = findContractNode(
|
||||
lookupIndex,
|
||||
link.from.repo,
|
||||
'consumer',
|
||||
link.from.symbolUid,
|
||||
link.from.symbolRef.filePath,
|
||||
link.from.symbolRef.name,
|
||||
);
|
||||
const toId = findContractNode(
|
||||
lookupIndex,
|
||||
link.to.repo,
|
||||
'provider',
|
||||
link.to.symbolUid,
|
||||
link.to.symbolRef.filePath,
|
||||
link.to.symbolRef.name,
|
||||
);
|
||||
if (!fromId || !toId) {
|
||||
report.linksDroppedMissingNode++;
|
||||
continue;
|
||||
}
|
||||
await queryBridge(
|
||||
handle,
|
||||
`
|
||||
MATCH (a:Contract), (b:Contract)
|
||||
WHERE a.id = $fromId AND b.id = $toId
|
||||
CREATE (a)-[:ContractLink {
|
||||
matchType: $matchType,
|
||||
confidence: $confidence,
|
||||
contractId: $contractId,
|
||||
fromRepo: $fromRepo,
|
||||
toRepo: $toRepo
|
||||
}]->(b)
|
||||
`,
|
||||
{
|
||||
fromId,
|
||||
toId,
|
||||
matchType: link.matchType,
|
||||
confidence: link.confidence,
|
||||
contractId: link.contractId,
|
||||
fromRepo: link.from.repo,
|
||||
toRepo: link.to.repo,
|
||||
},
|
||||
);
|
||||
report.linksInserted++;
|
||||
} catch (err) {
|
||||
report.linksFailed++;
|
||||
recordError('link', linkId, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Close temp DB (happy path). The finally block also calls
|
||||
// closeBridgeDb if we threw above; `handleClosed` prevents a
|
||||
// double-close on the native handle.
|
||||
await closeBridgeDb(handle);
|
||||
handleClosed = true;
|
||||
} finally {
|
||||
if (!handleClosed) {
|
||||
await closeBridgeDb(handle).catch(() => {
|
||||
/* ignore: cleanup path, best effort */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
|
||||
try {
|
||||
await fsp.access(finalPath);
|
||||
await retryRename(finalPath, bakPath);
|
||||
} catch {
|
||||
/* no existing db */
|
||||
}
|
||||
await retryRename(tmpPath, finalPath);
|
||||
try {
|
||||
await fsp.rm(bakPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// 4. Write meta.json
|
||||
await writeBridgeMeta(groupDir, {
|
||||
version: BRIDGE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
missingRepos: input.missingRepos,
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* openBridgeDbReadOnly */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHandle | null> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
try {
|
||||
await fsp.access(dbPath);
|
||||
} catch {
|
||||
// Check for .bak recovery. Use `retryRename` (not `fsp.rename`) for the
|
||||
// exact same reason the rest of this file does: the scenario that
|
||||
// triggers bak recovery is an interrupted writer, which on Windows may
|
||||
// still be holding an open handle on `.bak` for a few milliseconds when
|
||||
// a reader races in. EBUSY/EPERM retries recover that case silently.
|
||||
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
|
||||
try {
|
||||
await fsp.access(bakPath);
|
||||
await retryRename(bakPath, dbPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Version gate: check meta.json version compatibility
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
||||
return null; // incompatible schema version — fallback to JSON or re-sync
|
||||
}
|
||||
|
||||
// Open the native handle. If Connection construction throws AFTER
|
||||
// Database was successfully allocated, we'd leak the native Database
|
||||
// object. Wrap each step separately and tear down the partial handle.
|
||||
let db: lbug.Database | undefined;
|
||||
let conn: lbug.Connection | undefined;
|
||||
try {
|
||||
db = new lbug.Database(dbPath, 0, false, true); // readOnly
|
||||
conn = new lbug.Connection(db);
|
||||
return { _db: db, _conn: conn, groupDir } as BridgeHandle;
|
||||
} catch {
|
||||
if (conn) {
|
||||
try {
|
||||
await conn.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (db) {
|
||||
try {
|
||||
await db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* bridgeExists */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function bridgeExists(groupDir: string): Promise<boolean> {
|
||||
const handle = await openBridgeDbReadOnly(groupDir);
|
||||
if (!handle) return false;
|
||||
await closeBridgeDb(handle);
|
||||
return true;
|
||||
}
|
||||
60
gitnexus/src/core/group/bridge-schema.ts
Normal file
60
gitnexus/src/core/group/bridge-schema.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Bridge LadybugDB schema for cross-repo Contract Registry.
|
||||
* Separate from per-repo schema in lbug/schema.ts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Version of the bridge.lbug schema below. `openBridgeDbReadOnly` compares
|
||||
* this against `meta.json`'s version field and returns `null` on mismatch,
|
||||
* which trips the caller into either the JSON fallback path or a fresh
|
||||
* `group sync` that rebuilds `bridge.lbug` from scratch.
|
||||
*
|
||||
* Migration contract for contributors bumping this constant:
|
||||
* 1. Bump the number (e.g. `1` → `2`).
|
||||
* 2. Update the DDL below to match the new schema.
|
||||
* 3. DO NOT attempt an online migration in this file — the version gate
|
||||
* is intentionally a "discard and re-sync" strategy for V1. An old
|
||||
* bridge.lbug whose version doesn't match is treated as opaque and
|
||||
* rebuilt by the next `group sync`.
|
||||
* 4. If online migration becomes necessary (e.g. when groups accumulate
|
||||
* large amounts of embedding data), add a migration path as a
|
||||
* separate `bridge-migrations.ts` module rather than bloating this
|
||||
* file — keep schema and migration concerns separate.
|
||||
*/
|
||||
export const BRIDGE_SCHEMA_VERSION = 1;
|
||||
|
||||
export const CONTRACT_SCHEMA = `
|
||||
CREATE NODE TABLE Contract (
|
||||
id STRING,
|
||||
contractId STRING,
|
||||
type STRING,
|
||||
role STRING,
|
||||
repo STRING,
|
||||
service STRING DEFAULT '',
|
||||
symbolUid STRING DEFAULT '',
|
||||
filePath STRING DEFAULT '',
|
||||
symbolName STRING DEFAULT '',
|
||||
confidence DOUBLE DEFAULT 0.0,
|
||||
meta STRING DEFAULT '{}',
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
export const REPO_SNAPSHOT_SCHEMA = `
|
||||
CREATE NODE TABLE RepoSnapshot (
|
||||
id STRING,
|
||||
indexedAt STRING DEFAULT '',
|
||||
lastCommit STRING DEFAULT '',
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
export const CONTRACT_LINK_SCHEMA = `
|
||||
CREATE REL TABLE ContractLink (
|
||||
FROM Contract TO Contract,
|
||||
matchType STRING,
|
||||
confidence DOUBLE,
|
||||
contractId STRING,
|
||||
fromRepo STRING,
|
||||
toRepo STRING
|
||||
)`;
|
||||
|
||||
export const BRIDGE_SCHEMA_QUERIES = [CONTRACT_SCHEMA, REPO_SNAPSHOT_SCHEMA, CONTRACT_LINK_SCHEMA];
|
||||
|
|
@ -5,6 +5,15 @@ export interface MatchResult {
|
|||
unmatched: StoredContract[];
|
||||
}
|
||||
|
||||
export interface WildcardMatchResult {
|
||||
matched: CrossLink[];
|
||||
remaining: StoredContract[];
|
||||
}
|
||||
|
||||
function isGrpcWildcard(cid: string): boolean {
|
||||
return cid.startsWith('grpc::') && cid.endsWith('/*');
|
||||
}
|
||||
|
||||
export function normalizeContractId(id: string): string {
|
||||
const colonIdx = id.indexOf('::');
|
||||
if (colonIdx === -1) return id;
|
||||
|
|
@ -24,6 +33,22 @@ export function normalizeContractId(id: string): string {
|
|||
return id;
|
||||
}
|
||||
case 'grpc': {
|
||||
// Canonical form: `grpc::<lowercased-package-or-service>[/<method>]`.
|
||||
//
|
||||
// The package/service segment is lowercased because gRPC package
|
||||
// names are effectively case-insensitive across language bindings
|
||||
// (`auth.AuthService`, `auth.authservice`, `AUTH.AUTHSERVICE` all
|
||||
// describe the same wire protocol service). The RPC method segment
|
||||
// is preserved as-is because the HTTP/2 path used on the wire is
|
||||
// case-sensitive per the gRPC spec (`/Service/MethodName`), and
|
||||
// method names in generated clients match the proto source exactly.
|
||||
//
|
||||
// A package-only id (no slash) and a package/method id are treated
|
||||
// as DISTINCT canonical forms: `grpc::userservice` does not match
|
||||
// `grpc::userservice/Login`. That's by design — callers that want
|
||||
// service-level manifest matching against method-level providers
|
||||
// should use the gRPC wildcard form `grpc::UserService/*` which is
|
||||
// handled by runWildcardMatch below.
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx > 0) {
|
||||
const pkg = rest.substring(0, slashIdx).toLowerCase();
|
||||
|
|
@ -31,12 +56,12 @@ export function normalizeContractId(id: string): string {
|
|||
return `grpc::${pkg}${method}`;
|
||||
}
|
||||
if (slashIdx === 0) {
|
||||
// Malformed "package/method" with leading slash — do not lowercase the whole string
|
||||
// (method segment is case-sensitive per spec).
|
||||
// Malformed "/method" with leading slash — keep as-is so two
|
||||
// equally malformed ids can still match each other.
|
||||
return `grpc::${rest}`;
|
||||
}
|
||||
// No slash: spec is ambiguous (package-only vs full service.method). MVP: lowercase
|
||||
// the whole token; differs from pkg/method split above where RPC method keeps case.
|
||||
// No slash: package/service only. Lowercase to match the package
|
||||
// segment produced by the pkg/method branch above.
|
||||
return `grpc::${rest.toLowerCase()}`;
|
||||
}
|
||||
case 'topic':
|
||||
|
|
@ -66,27 +91,36 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
|
|||
return [];
|
||||
}
|
||||
|
||||
export function runExactMatch(contracts: StoredContract[]): MatchResult {
|
||||
export function buildProviderIndex(contracts: StoredContract[]): Map<string, StoredContract[]> {
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
const providerIndex = new Map<string, StoredContract[]>();
|
||||
const index = new Map<string, StoredContract[]>();
|
||||
for (const p of providers) {
|
||||
const key = normalizeContractId(p.contractId);
|
||||
const list = providerIndex.get(key) || [];
|
||||
const list = index.get(key) || [];
|
||||
list.push(p);
|
||||
providerIndex.set(key, list);
|
||||
index.set(key, list);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function runExactMatch(
|
||||
contracts: StoredContract[],
|
||||
providerIndex?: Map<string, StoredContract[]>,
|
||||
): MatchResult {
|
||||
const index = providerIndex ?? buildProviderIndex(contracts);
|
||||
|
||||
// Skip gRPC wildcard consumers — they go to wildcard pass only
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId));
|
||||
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
const matchedProviderIds = new Set<string>();
|
||||
|
||||
for (const consumer of consumers) {
|
||||
const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex);
|
||||
const matchingKeys = findMatchingKeys(consumer.contractId, index);
|
||||
if (matchingKeys.length === 0) continue;
|
||||
|
||||
const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []);
|
||||
const allMatchingProviders = matchingKeys.flatMap((k) => index.get(k) || []);
|
||||
for (const provider of allMatchingProviders) {
|
||||
if (provider.repo === consumer.repo) {
|
||||
if (!provider.service || !consumer.service || provider.service === consumer.service) {
|
||||
|
|
@ -118,10 +152,86 @@ export function runExactMatch(contracts: StoredContract[]): MatchResult {
|
|||
}
|
||||
}
|
||||
|
||||
const unmatched = contracts.filter((c) => {
|
||||
// normalUnmatched: contracts that weren't matched in exact pass
|
||||
const normalUnmatched = contracts.filter((c) => {
|
||||
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
const id = `${c.repo}::${c.contractId}`;
|
||||
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
|
||||
});
|
||||
|
||||
// Re-add gRPC wildcard contracts — they were never in exact matching
|
||||
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
|
||||
const unmatched = [...normalUnmatched, ...grpcWildcards];
|
||||
|
||||
return { matched, unmatched };
|
||||
}
|
||||
|
||||
export function runWildcardMatch(
|
||||
unmatched: StoredContract[],
|
||||
providerIndex: Map<string, StoredContract[]>,
|
||||
): WildcardMatchResult {
|
||||
const wildcardConsumers = unmatched.filter(
|
||||
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
|
||||
);
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
|
||||
for (const consumer of wildcardConsumers) {
|
||||
const normalized = normalizeContractId(consumer.contractId);
|
||||
// "grpc::com.example.userservice/*" → "com.example.userservice"
|
||||
// "grpc::userservice/*" → "userservice"
|
||||
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
|
||||
|
||||
for (const [key, providers] of providerIndex) {
|
||||
// Only match against non-wildcard gRPC providers (method-level IDs)
|
||||
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
|
||||
const afterPrefix = key.slice(6); // strip "grpc::"
|
||||
const slashIdx = afterPrefix.indexOf('/');
|
||||
if (slashIdx < 0) continue;
|
||||
const providerFqService = afterPrefix.slice(0, slashIdx);
|
||||
|
||||
// Match: exact FQ service, or bare-name match when consumer has no package
|
||||
const isMatch =
|
||||
providerFqService === fqService ||
|
||||
(!fqService.includes('.') && providerFqService.endsWith('.' + fqService));
|
||||
|
||||
if (!isMatch) continue;
|
||||
|
||||
for (const provider of providers) {
|
||||
// Skip same-repo same-service (same logic as runExactMatch)
|
||||
if (provider.repo === consumer.repo) {
|
||||
if (!provider.service || !consumer.service || provider.service === consumer.service) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
matched.push({
|
||||
from: {
|
||||
repo: consumer.repo,
|
||||
service: consumer.service,
|
||||
symbolUid: consumer.symbolUid,
|
||||
symbolRef: consumer.symbolRef,
|
||||
},
|
||||
to: {
|
||||
repo: provider.repo,
|
||||
service: provider.service,
|
||||
symbolUid: provider.symbolUid,
|
||||
symbolRef: provider.symbolRef,
|
||||
},
|
||||
type: consumer.type,
|
||||
contractId: consumer.contractId, // consumer's wildcard ID
|
||||
matchType: 'wildcard',
|
||||
confidence: Math.min(provider.confidence, consumer.confidence),
|
||||
});
|
||||
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = unmatched.filter((c) => {
|
||||
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
|
||||
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
|
||||
});
|
||||
|
||||
return { matched, remaining };
|
||||
}
|
||||
|
|
|
|||
124
gitnexus/src/core/group/normalization.ts
Normal file
124
gitnexus/src/core/group/normalization.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a contract by how much information it carries, so `dedupeContracts`
|
||||
* can prefer the "richer" record when two contracts collide on the same
|
||||
* `(repo, contractId, role, filePath)` key.
|
||||
*
|
||||
* Weights express a priority ordering, not calibrated probabilities:
|
||||
* +3 — `symbolUid` resolved (tier 1 of the downstream lookup — highest
|
||||
* signal because it's the strongest anchor for cross-impact traversal
|
||||
* and the only one that's robust to renames)
|
||||
* +2 — any of `filePath`, `symbolRef.name`, or `symbolName` that's more
|
||||
* specific than the contractId itself (tier 2 signal — resolves
|
||||
* uniquely in most cases and survives across syncs)
|
||||
* +1 — `service` tag (monorepo attribution — useful but not sufficient
|
||||
* on its own) or non-manifest origin (auto-extracted contracts are
|
||||
* preferred over manifest-declared synthetic ones because the former
|
||||
* are grounded in real source code)
|
||||
*
|
||||
* The absolute numbers don't matter, only their relative ordering.
|
||||
*/
|
||||
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()];
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
|
||||
export type MatchType = 'exact' | 'manifest' | 'bm25' | 'embedding';
|
||||
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
|
||||
export type ContractRole = 'provider' | 'consumer';
|
||||
|
||||
export interface GroupConfig {
|
||||
|
|
@ -131,3 +131,17 @@ export interface OutOfScopeLink {
|
|||
contractId: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** Opaque handle to an open bridge LadybugDB. */
|
||||
export interface BridgeHandle {
|
||||
/** Internal — do not access directly. */
|
||||
readonly _db: unknown;
|
||||
readonly _conn: unknown;
|
||||
readonly groupDir: string;
|
||||
}
|
||||
|
||||
export interface BridgeMeta {
|
||||
version: number;
|
||||
generatedAt: string;
|
||||
missingRepos: string[];
|
||||
}
|
||||
|
|
|
|||
178
gitnexus/test/unit/group/bridge-db-edge.test.ts
Normal file
178
gitnexus/test/unit/group/bridge-db-edge.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
writeBridge,
|
||||
openBridgeDbReadOnly,
|
||||
queryBridge,
|
||||
closeBridgeDb,
|
||||
} from '../../../src/core/group/bridge-db.js';
|
||||
import type { CrossLink } from '../../../src/core/group/types.js';
|
||||
import { makeContract } from './fixtures.js';
|
||||
|
||||
describe('bridge-db edge cases', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-edge-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test_openBridgeDbReadOnly_version_gate_returns_null_for_incompatible', async () => {
|
||||
// Create a dummy bridge.lbug file so the access check passes
|
||||
await fsp.writeFile(path.join(tmpDir, 'bridge.lbug'), 'dummy');
|
||||
// Write meta.json with an incompatible version (999)
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'meta.json'),
|
||||
JSON.stringify({ version: 999, generatedAt: '', missingRepos: [] }),
|
||||
);
|
||||
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).toBeNull();
|
||||
});
|
||||
|
||||
it('test_openBridgeDbReadOnly_bak_recovery_restores_bridge', async () => {
|
||||
// Write a valid bridge
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract()],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
// Move bridge.lbug → bridge.lbug.bak (simulating interrupted swap)
|
||||
const dbPath = path.join(tmpDir, 'bridge.lbug');
|
||||
const bakPath = path.join(tmpDir, 'bridge.lbug.bak');
|
||||
await fsp.rename(dbPath, bakPath);
|
||||
|
||||
// openBridgeDbReadOnly should auto-recover from .bak
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).not.toBeNull();
|
||||
const rows = await queryBridge<{ repo: string }>(
|
||||
handle!,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_writeBridge_crossLink_with_missing_to_node_silently_skipped', async () => {
|
||||
const provider = makeContract({ repo: 'backend', role: 'provider' });
|
||||
const consumer = makeContract({
|
||||
repo: 'frontend',
|
||||
role: 'consumer',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
symbolName: 'fetchUsers',
|
||||
});
|
||||
// CrossLink referencing a 'to' endpoint that doesn't match any contract node
|
||||
const link: CrossLink = {
|
||||
from: {
|
||||
repo: 'frontend',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
},
|
||||
to: {
|
||||
repo: 'nonexistent-repo',
|
||||
symbolUid: 'uid-missing',
|
||||
symbolRef: { filePath: 'src/missing.ts', name: 'missingFn' },
|
||||
},
|
||||
type: 'http',
|
||||
contractId: 'http::GET::/api/users',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
};
|
||||
|
||||
// Should not throw — the link is silently skipped
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [provider, consumer],
|
||||
crossLinks: [link],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).not.toBeNull();
|
||||
// No cross-links should exist since 'to' node was missing
|
||||
const rows = await queryBridge<{ matchType: string }>(
|
||||
handle!,
|
||||
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.matchType AS matchType',
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
// But contracts should still be present
|
||||
const contractRows = await queryBridge<{ repo: string }>(
|
||||
handle!,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo',
|
||||
);
|
||||
expect(contractRows).toHaveLength(2);
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_writeBridge_manifest_grpc_link_with_symbol_uids_persists_queryable_contract_edge', async () => {
|
||||
const provider = makeContract({
|
||||
contractId: 'grpc::auth.AuthService/Login',
|
||||
type: 'grpc',
|
||||
role: 'provider',
|
||||
repo: 'platform/auth',
|
||||
symbolUid: 'uid-auth-login',
|
||||
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
|
||||
symbolName: 'auth.AuthService/Login',
|
||||
});
|
||||
const consumer = makeContract({
|
||||
contractId: 'grpc::auth.AuthService/Login',
|
||||
type: 'grpc',
|
||||
role: 'consumer',
|
||||
repo: 'platform/orders',
|
||||
symbolUid: 'uid-orders-client',
|
||||
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
|
||||
symbolName: 'auth.AuthService/Login',
|
||||
});
|
||||
const link: CrossLink = {
|
||||
from: {
|
||||
repo: 'platform/orders',
|
||||
symbolUid: 'uid-orders-client',
|
||||
symbolRef: { filePath: 'src/client.ts', name: 'AuthServiceClient' },
|
||||
},
|
||||
to: {
|
||||
repo: 'platform/auth',
|
||||
symbolUid: 'uid-auth-login',
|
||||
symbolRef: { filePath: 'src/auth.proto', name: 'Login' },
|
||||
},
|
||||
type: 'grpc',
|
||||
contractId: 'grpc::auth.AuthService/Login',
|
||||
matchType: 'manifest',
|
||||
confidence: 1.0,
|
||||
};
|
||||
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [provider, consumer],
|
||||
crossLinks: [link],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).not.toBeNull();
|
||||
const rows = await queryBridge<{
|
||||
contractId: string;
|
||||
matchType: string;
|
||||
fromRepo: string;
|
||||
toRepo: string;
|
||||
}>(
|
||||
handle!,
|
||||
`MATCH (a:Contract)-[l:ContractLink]->(b:Contract)
|
||||
RETURN l.contractId AS contractId, l.matchType AS matchType, l.fromRepo AS fromRepo, l.toRepo AS toRepo`,
|
||||
);
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
contractId: 'grpc::auth.AuthService/Login',
|
||||
matchType: 'manifest',
|
||||
fromRepo: 'platform/orders',
|
||||
toRepo: 'platform/auth',
|
||||
},
|
||||
]);
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
});
|
||||
575
gitnexus/test/unit/group/bridge-db.test.ts
Normal file
575
gitnexus/test/unit/group/bridge-db.test.ts
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
openBridgeDb,
|
||||
ensureBridgeSchema,
|
||||
queryBridge,
|
||||
closeBridgeDb,
|
||||
contractNodeId,
|
||||
retryRename,
|
||||
writeBridge,
|
||||
openBridgeDbReadOnly,
|
||||
readBridgeMeta,
|
||||
bridgeExists,
|
||||
createContractLookupIndex,
|
||||
indexContract,
|
||||
findContractNode,
|
||||
} from '../../../src/core/group/bridge-db.js';
|
||||
import type { CrossLink } from '../../../src/core/group/types.js';
|
||||
import { makeContract } from './fixtures.js';
|
||||
|
||||
describe('bridge-db core', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test_openBridgeDb_returns_handle_and_closes', async () => {
|
||||
const dbPath = path.join(tmpDir, 'test.lbug');
|
||||
const handle = await openBridgeDb(dbPath);
|
||||
expect(handle).toBeDefined();
|
||||
expect(handle._db).toBeDefined();
|
||||
expect(handle._conn).toBeDefined();
|
||||
expect(handle.groupDir).toBe(tmpDir);
|
||||
// Close should not throw
|
||||
await closeBridgeDb(handle);
|
||||
});
|
||||
|
||||
it('test_ensureBridgeSchema_creates_tables_idempotent', async () => {
|
||||
const dbPath = path.join(tmpDir, 'test.lbug');
|
||||
const handle = await openBridgeDb(dbPath);
|
||||
await ensureBridgeSchema(handle);
|
||||
// Run again — should not throw
|
||||
await ensureBridgeSchema(handle);
|
||||
const rows = await queryBridge<{ cnt: number }>(
|
||||
handle,
|
||||
'MATCH (c:Contract) RETURN count(c) AS cnt',
|
||||
);
|
||||
expect(rows[0].cnt).toBe(0);
|
||||
await closeBridgeDb(handle);
|
||||
});
|
||||
|
||||
it('test_queryBridge_returns_inserted_data', async () => {
|
||||
const dbPath = path.join(tmpDir, 'test.lbug');
|
||||
const handle = await openBridgeDb(dbPath);
|
||||
await ensureBridgeSchema(handle);
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (c:Contract {
|
||||
id: 'abc123', contractId: 'http::GET::/api', type: 'http', role: 'provider',
|
||||
repo: 'backend', confidence: 0.9
|
||||
})`,
|
||||
);
|
||||
const rows = await queryBridge<{ repo: string; confidence: number }>(
|
||||
handle,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo, c.confidence AS confidence',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].repo).toBe('backend');
|
||||
expect(rows[0].confidence).toBe(0.9);
|
||||
await closeBridgeDb(handle);
|
||||
});
|
||||
|
||||
it('test_queryBridge_parameterized', async () => {
|
||||
const dbPath = path.join(tmpDir, 'test.lbug');
|
||||
const handle = await openBridgeDb(dbPath);
|
||||
await ensureBridgeSchema(handle);
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (c:Contract {
|
||||
id: 'p1', contractId: 'http::GET::/api', type: 'http', role: 'provider',
|
||||
repo: 'backend', confidence: 0.9
|
||||
})`,
|
||||
);
|
||||
const rows = await queryBridge<{ repo: string }>(
|
||||
handle,
|
||||
'MATCH (c:Contract) WHERE c.repo = $r RETURN c.repo AS repo',
|
||||
{ r: 'backend' },
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].repo).toBe('backend');
|
||||
await closeBridgeDb(handle);
|
||||
});
|
||||
|
||||
it('test_contractNodeId_full_sha256', () => {
|
||||
const id = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts');
|
||||
expect(id).toHaveLength(64); // full SHA-256 hex
|
||||
// Same inputs → same hash
|
||||
const id2 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/routes.ts');
|
||||
expect(id).toBe(id2);
|
||||
// Different filePath → different hash
|
||||
const id3 = contractNodeId('backend', 'http::GET::/api', 'provider', 'src/other.ts');
|
||||
expect(id).not.toBe(id3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeBridge + read', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-write-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test_writeBridge_creates_bridge_lbug_file', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract()],
|
||||
crossLinks: [],
|
||||
repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } },
|
||||
missingRepos: ['missing-repo'],
|
||||
});
|
||||
const exists = await bridgeExists(tmpDir);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('test_writeBridge_returns_report_with_insert_counts', async () => {
|
||||
const report = await writeBridge(tmpDir, {
|
||||
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
|
||||
crossLinks: [],
|
||||
repoSnapshots: { backend: { indexedAt: '2026-01-01', lastCommit: 'abc' } },
|
||||
missingRepos: [],
|
||||
});
|
||||
expect(report.contractsInserted).toBe(2);
|
||||
expect(report.contractsFailed).toBe(0);
|
||||
expect(report.snapshotsInserted).toBe(1);
|
||||
expect(report.snapshotsFailed).toBe(0);
|
||||
expect(report.linksInserted).toBe(0);
|
||||
expect(report.linksFailed).toBe(0);
|
||||
expect(report.linksDroppedMissingNode).toBe(0);
|
||||
expect(report.sampleErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('test_writeBridge_counts_dropped_links_with_missing_nodes', async () => {
|
||||
// Provider + cross-link that references a non-existent consumer node →
|
||||
// findContractNode returns null for `from`, link gets dropped.
|
||||
const provider = makeContract({ role: 'provider' });
|
||||
const report = await writeBridge(tmpDir, {
|
||||
contracts: [provider],
|
||||
crossLinks: [
|
||||
{
|
||||
from: {
|
||||
repo: 'ghost',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'nowhere.ts', name: 'ghostFn' },
|
||||
},
|
||||
to: {
|
||||
repo: provider.repo,
|
||||
symbolUid: provider.symbolUid,
|
||||
symbolRef: provider.symbolRef,
|
||||
},
|
||||
type: 'http',
|
||||
contractId: provider.contractId,
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
},
|
||||
],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
expect(report.linksInserted).toBe(0);
|
||||
expect(report.linksDroppedMissingNode).toBe(1);
|
||||
expect(report.linksFailed).toBe(0);
|
||||
expect(report.contractsInserted).toBe(1);
|
||||
});
|
||||
|
||||
it('test_writeBridge_contracts_queryable', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
expect(handle).not.toBeNull();
|
||||
const rows = await queryBridge<{ repo: string }>(
|
||||
handle!,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_writeBridge_meta_json_persists_missingRepos', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: ['repo-a', 'repo-b'],
|
||||
});
|
||||
const meta = await readBridgeMeta(tmpDir);
|
||||
expect(meta.missingRepos).toEqual(['repo-a', 'repo-b']);
|
||||
expect(meta.version).toBeGreaterThan(0);
|
||||
expect(meta.generatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('test_writeBridge_repoSnapshots_queryable', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
repoSnapshots: { 'hr/backend': { indexedAt: '2026-01-01', lastCommit: 'abc' } },
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
const rows = await queryBridge<{ id: string; indexedAt: string }>(
|
||||
handle!,
|
||||
'MATCH (s:RepoSnapshot) RETURN s.id AS id, s.indexedAt AS indexedAt',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('hr/backend');
|
||||
expect(rows[0].indexedAt).toBe('2026-01-01');
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_writeBridge_crossLinks_queryable', async () => {
|
||||
const provider = makeContract({ repo: 'backend', role: 'provider' });
|
||||
const consumer = makeContract({
|
||||
repo: 'frontend',
|
||||
role: 'consumer',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
symbolName: 'fetchUsers',
|
||||
});
|
||||
const link: CrossLink = {
|
||||
from: {
|
||||
repo: 'frontend',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' },
|
||||
},
|
||||
to: {
|
||||
repo: 'backend',
|
||||
symbolUid: 'uid-1',
|
||||
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
|
||||
},
|
||||
type: 'http',
|
||||
contractId: 'http::GET::/api/users',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
};
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [provider, consumer],
|
||||
crossLinks: [link],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
const rows = await queryBridge<{ fromRepo: string; toRepo: string; matchType: string }>(
|
||||
handle!,
|
||||
'MATCH (a:Contract)-[l:ContractLink]->(b:Contract) RETURN l.fromRepo AS fromRepo, l.toRepo AS toRepo, l.matchType AS matchType',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].fromRepo).toBe('frontend');
|
||||
expect(rows[0].toRepo).toBe('backend');
|
||||
expect(rows[0].matchType).toBe('exact');
|
||||
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();
|
||||
});
|
||||
|
||||
it('test_bridgeExists_false_for_missing', async () => {
|
||||
expect(await bridgeExists(path.join(tmpDir, 'nonexistent'))).toBe(false);
|
||||
});
|
||||
|
||||
it('test_writeBridge_overwrites_previous', async () => {
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract()],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
await writeBridge(tmpDir, {
|
||||
contracts: [makeContract({ repo: 'new-repo' })],
|
||||
crossLinks: [],
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
});
|
||||
const handle = await openBridgeDbReadOnly(tmpDir);
|
||||
const rows = await queryBridge<{ repo: string }>(
|
||||
handle!,
|
||||
'MATCH (c:Contract) RETURN c.repo AS repo',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].repo).toBe('new-repo');
|
||||
await closeBridgeDb(handle!);
|
||||
});
|
||||
|
||||
it('test_readBridgeMeta_returns_defaults_for_missing', async () => {
|
||||
const meta = await readBridgeMeta(path.join(tmpDir, 'nonexistent'));
|
||||
expect(meta.version).toBe(0);
|
||||
expect(meta.generatedAt).toBe('');
|
||||
expect(meta.missingRepos).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retryRename', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('retries on EBUSY and eventually succeeds', async () => {
|
||||
// Spy on fs.promises.rename and make the first two attempts fail with
|
||||
// EBUSY, then succeed on the third. Verifies that Windows-style
|
||||
// transient rename failures don't immediately bubble up.
|
||||
const attempts: Array<[string, string]> = [];
|
||||
let calls = 0;
|
||||
const spy = vi.spyOn(fsp, 'rename').mockImplementation(async (src, dst) => {
|
||||
attempts.push([String(src), String(dst)]);
|
||||
calls++;
|
||||
if (calls < 3) {
|
||||
const err = new Error('resource busy or locked') as NodeJS.ErrnoException;
|
||||
err.code = 'EBUSY';
|
||||
throw err;
|
||||
}
|
||||
// Third attempt: pretend the rename worked.
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await retryRename('/src/a', '/dst/b', 3);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
expect(attempts.every(([s, d]) => s === '/src/a' && d === '/dst/b')).toBe(true);
|
||||
});
|
||||
|
||||
it('rethrows non-retryable errors immediately', async () => {
|
||||
// A non-retryable code (e.g. ENOENT) should NOT be swallowed into a
|
||||
// retry loop — that would mask real bugs and waste time.
|
||||
let calls = 0;
|
||||
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
|
||||
calls++;
|
||||
const err = new Error('no such file') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
await expect(retryRename('/src/a', '/dst/b', 5)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('gives up after the configured number of attempts', async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
|
||||
calls++;
|
||||
const err = new Error('locked') as NodeJS.ErrnoException;
|
||||
err.code = 'EPERM';
|
||||
throw err;
|
||||
});
|
||||
|
||||
await expect(retryRename('/src/a', '/dst/b', 3)).rejects.toMatchObject({ code: 'EPERM' });
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it('retries on EACCES as well', async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(fsp, 'rename').mockImplementation(async () => {
|
||||
calls++;
|
||||
if (calls < 2) {
|
||||
const err = new Error('permission denied') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await retryRename('/src/a', '/dst/b', 3);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findContractNode', () => {
|
||||
// Pure-function tests for the lookup index + three-tier resolver that
|
||||
// were previously an inner closure of `writeBridge` and therefore
|
||||
// untestable in isolation. Every test here builds its own index and
|
||||
// never touches the DB.
|
||||
|
||||
it('returns null on empty index', () => {
|
||||
const index = createContractLookupIndex();
|
||||
expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'foo')).toBeNull();
|
||||
});
|
||||
|
||||
it('tier 1: returns contract matched by symbolUid', () => {
|
||||
const index = createContractLookupIndex();
|
||||
const c = makeContract({ symbolUid: 'uid-42', repo: 'backend', role: 'provider' });
|
||||
indexContract(index, c, 'node-A');
|
||||
expect(findContractNode(index, 'backend', 'provider', 'uid-42', 'anywhere.ts', 'anyName')).toBe(
|
||||
'node-A',
|
||||
);
|
||||
});
|
||||
|
||||
it('tier 1 is repo-scoped: same uid in a different repo does not match', () => {
|
||||
const index = createContractLookupIndex();
|
||||
const c = makeContract({ symbolUid: 'uid-42', repo: 'backend' });
|
||||
indexContract(index, c, 'node-A');
|
||||
expect(
|
||||
findContractNode(index, 'frontend', 'provider', 'uid-42', 'src/routes.ts', 'getUsers'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('tier 1 is role-scoped: provider uid match does not resolve consumer query', () => {
|
||||
const index = createContractLookupIndex();
|
||||
const c = makeContract({ symbolUid: 'uid-42', role: 'provider', repo: 'backend' });
|
||||
indexContract(index, c, 'node-A');
|
||||
expect(
|
||||
findContractNode(index, 'backend', 'consumer', 'uid-42', 'src/routes.ts', 'getUsers'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('tier 2: falls through to filePath + symbolName when symbolUid is empty', () => {
|
||||
const index = createContractLookupIndex();
|
||||
const c = makeContract({
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' },
|
||||
symbolName: 'handler',
|
||||
});
|
||||
indexContract(index, c, 'node-B');
|
||||
expect(findContractNode(index, 'backend', 'provider', '', 'src/ctrl.ts', 'handler')).toBe(
|
||||
'node-B',
|
||||
);
|
||||
});
|
||||
|
||||
it('tier 2: falls through when the given symbolUid does not match anything', () => {
|
||||
const index = createContractLookupIndex();
|
||||
const c = makeContract({
|
||||
symbolUid: 'uid-real',
|
||||
symbolRef: { filePath: 'src/ctrl.ts', name: 'handler' },
|
||||
});
|
||||
indexContract(index, c, 'node-B');
|
||||
// Wrong uid; but filePath+name still resolves.
|
||||
expect(
|
||||
findContractNode(index, 'backend', 'provider', 'uid-wrong', 'src/ctrl.ts', 'handler'),
|
||||
).toBe('node-B');
|
||||
});
|
||||
|
||||
it('tier 3: resolves by filePath alone when exactly one contract lives there', () => {
|
||||
const index = createContractLookupIndex();
|
||||
const c = makeContract({
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/solo.ts', name: 'actualName' },
|
||||
});
|
||||
indexContract(index, c, 'node-C');
|
||||
// filePath+name miss (name is wrong), but tier 3 picks the sole entry.
|
||||
expect(findContractNode(index, 'backend', 'provider', '', 'src/solo.ts', 'wrongName')).toBe(
|
||||
'node-C',
|
||||
);
|
||||
});
|
||||
|
||||
it('tier 3: does NOT resolve when multiple contracts live in the same file', () => {
|
||||
const index = createContractLookupIndex();
|
||||
const a = makeContract({
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/multi.ts', name: 'handlerA' },
|
||||
});
|
||||
const b = makeContract({
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/multi.ts', name: 'handlerB' },
|
||||
contractId: 'http::GET::/api/b',
|
||||
});
|
||||
indexContract(index, a, 'node-MA');
|
||||
indexContract(index, b, 'node-MB');
|
||||
// Wrong symbolName → no tier 2 match. Two contracts in the same file
|
||||
// → tier 3 must refuse to guess.
|
||||
expect(
|
||||
findContractNode(index, 'backend', 'provider', '', 'src/multi.ts', 'unknown'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers tier 1 over tier 2 when both could resolve', () => {
|
||||
const index = createContractLookupIndex();
|
||||
const tier1Contract = makeContract({
|
||||
symbolUid: 'uid-1',
|
||||
symbolRef: { filePath: 'src/a.ts', name: 'first' },
|
||||
});
|
||||
const tier2Contract = makeContract({
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/a.ts', name: 'first' },
|
||||
contractId: 'http::POST::/api/x',
|
||||
});
|
||||
indexContract(index, tier1Contract, 'tier1-id');
|
||||
indexContract(index, tier2Contract, 'tier2-id');
|
||||
expect(findContractNode(index, 'backend', 'provider', 'uid-1', 'src/a.ts', 'first')).toBe(
|
||||
'tier1-id',
|
||||
);
|
||||
});
|
||||
});
|
||||
32
gitnexus/test/unit/group/fixtures.ts
Normal file
32
gitnexus/test/unit/group/fixtures.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Shared test fixtures for `test/unit/group/*` test files. Keep this small
|
||||
* and purpose-built — it's NOT a general-purpose factory. If a builder here
|
||||
* grows complex enough to need its own module, move it next to the code
|
||||
* under test (e.g. `bridge-db.fixtures.ts`) instead of ballooning this file.
|
||||
*/
|
||||
|
||||
import type { StoredContract } from '../../../src/core/group/types.js';
|
||||
|
||||
/**
|
||||
* Canonical baseline contract used by bridge-db and related tests. Every
|
||||
* field is populated so callers get a valid `StoredContract` with zero args,
|
||||
* and any field can be overridden via the partial — e.g.
|
||||
* `makeContract({ role: 'consumer', repo: 'frontend' })`.
|
||||
*
|
||||
* Prefer passing a `Partial<StoredContract>` override for the specific
|
||||
* field you care about rather than mutating the returned object in place.
|
||||
*/
|
||||
export function makeContract(overrides: Partial<StoredContract> = {}): StoredContract {
|
||||
return {
|
||||
contractId: 'http::GET::/api/users',
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-1',
|
||||
symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' },
|
||||
symbolName: 'getUsers',
|
||||
confidence: 0.85,
|
||||
meta: {},
|
||||
repo: 'backend',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { runExactMatch, normalizeContractId } from '../../../src/core/group/matching.js';
|
||||
import {
|
||||
runExactMatch,
|
||||
normalizeContractId,
|
||||
buildProviderIndex,
|
||||
runWildcardMatch,
|
||||
} from '../../../src/core/group/matching.js';
|
||||
import type { StoredContract } from '../../../src/core/group/types.js';
|
||||
|
||||
describe('normalizeContractId', () => {
|
||||
|
|
@ -21,6 +26,16 @@ describe('normalizeContractId', () => {
|
|||
expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing');
|
||||
});
|
||||
|
||||
it('handles malformed grpc with leading slash and no package', () => {
|
||||
// grpc::/Method — leading slash, no package
|
||||
expect(normalizeContractId('grpc::/Method')).toBe('grpc::/Method');
|
||||
});
|
||||
|
||||
it('handles grpc with no slash at all', () => {
|
||||
// grpc::ServiceName — no slash, ambiguous; MVP: lowercase entire token
|
||||
expect(normalizeContractId('grpc::ServiceName')).toBe('grpc::servicename');
|
||||
});
|
||||
|
||||
it('trims and lowercases topic', () => {
|
||||
expect(normalizeContractId('topic:: Employee.Hired ')).toBe('topic::employee.hired');
|
||||
});
|
||||
|
|
@ -180,3 +195,211 @@ describe('runExactMatch', () => {
|
|||
expect(unmatched).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for Task 6 tests
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeGrpcContract(
|
||||
id: string,
|
||||
role: 'provider' | 'consumer',
|
||||
repo: string,
|
||||
overrides: Partial<StoredContract> = {},
|
||||
): StoredContract {
|
||||
return {
|
||||
contractId: id,
|
||||
type: 'grpc',
|
||||
role,
|
||||
symbolUid: `uid-${repo}-${id}`,
|
||||
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
|
||||
symbolName: `fn-${id}`,
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildProviderIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildProviderIndex', () => {
|
||||
it('test_buildProviderIndex_creates_normalized_keys', () => {
|
||||
const contracts: StoredContract[] = [
|
||||
makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'provider', 'backend'),
|
||||
makeGrpcContract('grpc::Com.Example.UserService/GetUser', 'consumer', 'frontend'),
|
||||
];
|
||||
|
||||
const index = buildProviderIndex(contracts);
|
||||
|
||||
// Only providers should be in the index
|
||||
expect(index.size).toBe(1);
|
||||
// Key should be normalized (lowercased package)
|
||||
expect(index.has('grpc::com.example.userservice/GetUser')).toBe(true);
|
||||
expect(index.get('grpc::com.example.userservice/GetUser')).toHaveLength(1);
|
||||
expect(index.get('grpc::com.example.userservice/GetUser')![0].role).toBe('provider');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runExactMatch — gRPC wildcard skip
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('runExactMatch — gRPC wildcard handling', () => {
|
||||
it('test_runExactMatch_skips_grpc_wildcard_contracts', () => {
|
||||
const contracts: StoredContract[] = [
|
||||
makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend'),
|
||||
makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend'),
|
||||
];
|
||||
|
||||
const { matched, unmatched } = runExactMatch(contracts);
|
||||
|
||||
// gRPC wildcards should NOT be matched in exact pass
|
||||
expect(matched).toHaveLength(0);
|
||||
// Both should appear in unmatched
|
||||
expect(unmatched).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('test_runExactMatch_does_not_skip_http_wildcards', () => {
|
||||
const contracts: StoredContract[] = [
|
||||
{
|
||||
contractId: 'http::GET::/api/users',
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-backend-http',
|
||||
symbolRef: { filePath: 'src/backend.ts', name: 'fn-http' },
|
||||
symbolName: 'fn-http',
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo: 'backend',
|
||||
},
|
||||
{
|
||||
contractId: 'http::*::/api/users',
|
||||
type: 'http',
|
||||
role: 'consumer',
|
||||
symbolUid: 'uid-frontend-http',
|
||||
symbolRef: { filePath: 'src/frontend.ts', name: 'fn-http' },
|
||||
symbolName: 'fn-http',
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo: 'frontend',
|
||||
},
|
||||
];
|
||||
|
||||
const { matched } = runExactMatch(contracts);
|
||||
// HTTP wildcard should still match via findMatchingKeys
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].contractId).toBe('http::*::/api/users');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runWildcardMatch
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('runWildcardMatch', () => {
|
||||
it('test_runWildcardMatch_fq_service_match', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].from.repo).toBe('frontend');
|
||||
expect(matched[0].to.repo).toBe('backend');
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_bare_name_match', () => {
|
||||
const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].from.repo).toBe('frontend');
|
||||
expect(matched[0].to.repo).toBe('backend');
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_no_match_different_service', () => {
|
||||
const consumer = makeGrpcContract('grpc::UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.OtherService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(0);
|
||||
expect(remaining).toContainEqual(consumer);
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_skips_wildcard_providers', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract('grpc::com.example.UserService/*', 'provider', 'backend');
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
// Wildcard provider key ends with /*, so it should be skipped
|
||||
expect(matched).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_confidence_min', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend', {
|
||||
confidence: 0.7,
|
||||
});
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
{
|
||||
confidence: 0.5,
|
||||
},
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].confidence).toBe(0.5);
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_matchType_wildcard', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].matchType).toBe('wildcard');
|
||||
});
|
||||
|
||||
it('test_runWildcardMatch_contractId_is_consumers', () => {
|
||||
const consumer = makeGrpcContract('grpc::com.example.UserService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::com.example.UserService/GetUser',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].contractId).toBe('grpc::com.example.UserService/*');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue