feat(group): bridge.lbug storage + contract matching expansion (1/4 of #606 split) (#795)

* 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 &lt;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:
ivkond 2026-04-11 21:46:12 +03:00 • committed by GitHub
parent 9364739fb4
commit 1ff324ca16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1919 additions and 15 deletions

View 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;
}

View 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];

View file

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

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

View file

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

View 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!);
});
});

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

View 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,
};
}

View file

@ -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/*');
});
});