From 1ff324ca16bfde5b0706a6869b32ea4e47932b20 Mon Sep 17 00:00:00 2001 From: ivkond Date: Sat, 11 Apr 2026 21:46:12 +0300 Subject: [PATCH] feat(group): bridge.lbug storage + contract matching expansion (1/4 of #606 split) (#795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude --- gitnexus/src/core/group/bridge-db.ts | 588 ++++++++++++++++++ gitnexus/src/core/group/bridge-schema.ts | 60 ++ gitnexus/src/core/group/matching.ts | 136 +++- gitnexus/src/core/group/normalization.ts | 124 ++++ gitnexus/src/core/group/types.ts | 16 +- .../test/unit/group/bridge-db-edge.test.ts | 178 ++++++ gitnexus/test/unit/group/bridge-db.test.ts | 575 +++++++++++++++++ gitnexus/test/unit/group/fixtures.ts | 32 + gitnexus/test/unit/group/matching.test.ts | 225 ++++++- 9 files changed, 1919 insertions(+), 15 deletions(-) create mode 100644 gitnexus/src/core/group/bridge-db.ts create mode 100644 gitnexus/src/core/group/bridge-schema.ts create mode 100644 gitnexus/src/core/group/normalization.ts create mode 100644 gitnexus/test/unit/group/bridge-db-edge.test.ts create mode 100644 gitnexus/test/unit/group/bridge-db.test.ts create mode 100644 gitnexus/test/unit/group/fixtures.ts diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts new file mode 100644 index 000000000..864a79599 --- /dev/null +++ b/gitnexus/src/core/group/bridge-db.ts @@ -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` + * 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; + /** tier 2: `repo + role + filePath + symbolName` → contract node id */ + byRef: Map; + /** tier 3: `repo + role + filePath` → list of contract node ids in that file */ + byFile: Map; +} + +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 { + 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 { + 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( + handle: BridgeHandle, + cypher: string, + params?: Record, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + 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 { + 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 { + 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 { + const handle = await openBridgeDbReadOnly(groupDir); + if (!handle) return false; + await closeBridgeDb(handle); + return true; +} diff --git a/gitnexus/src/core/group/bridge-schema.ts b/gitnexus/src/core/group/bridge-schema.ts new file mode 100644 index 000000000..d61680390 --- /dev/null +++ b/gitnexus/src/core/group/bridge-schema.ts @@ -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]; diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 6d39f4ce4..ec793968b 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -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::[/]`. + // + // 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 { const providers = contracts.filter((c) => c.role === 'provider'); - const consumers = contracts.filter((c) => c.role === 'consumer'); - - const providerIndex = new Map(); + const index = new Map(); 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, +): 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(); const matchedProviderIds = new Set(); 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, +): WildcardMatchResult { + const wildcardConsumers = unmatched.filter( + (c) => c.role === 'consumer' && isGrpcWildcard(c.contractId), + ); + const matched: CrossLink[] = []; + const matchedConsumerIds = new Set(); + + 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 }; +} diff --git a/gitnexus/src/core/group/normalization.ts b/gitnexus/src/core/group/normalization.ts new file mode 100644 index 000000000..c99d36850 --- /dev/null +++ b/gitnexus/src/core/group/normalization.ts @@ -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(); + 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(); + 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()]; +} diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 7ab0f071a..b9ba97582 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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[]; +} diff --git a/gitnexus/test/unit/group/bridge-db-edge.test.ts b/gitnexus/test/unit/group/bridge-db-edge.test.ts new file mode 100644 index 000000000..ca85468cc --- /dev/null +++ b/gitnexus/test/unit/group/bridge-db-edge.test.ts @@ -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!); + }); +}); diff --git a/gitnexus/test/unit/group/bridge-db.test.ts b/gitnexus/test/unit/group/bridge-db.test.ts new file mode 100644 index 000000000..e52b14c17 --- /dev/null +++ b/gitnexus/test/unit/group/bridge-db.test.ts @@ -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', + ); + }); +}); diff --git a/gitnexus/test/unit/group/fixtures.ts b/gitnexus/test/unit/group/fixtures.ts new file mode 100644 index 000000000..a3d63d6b1 --- /dev/null +++ b/gitnexus/test/unit/group/fixtures.ts @@ -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` override for the specific + * field you care about rather than mutating the returned object in place. + */ +export function makeContract(overrides: Partial = {}): 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, + }; +} diff --git a/gitnexus/test/unit/group/matching.test.ts b/gitnexus/test/unit/group/matching.test.ts index bbbe5f664..c5713d909 100644 --- a/gitnexus/test/unit/group/matching.test.ts +++ b/gitnexus/test/unit/group/matching.test.ts @@ -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 { + 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/*'); + }); +});