From b1c4c56f23970277c99dc5fb81775227249ed723 Mon Sep 17 00:00:00 2001 From: ivkond Date: Sat, 4 Apr 2026 23:29:01 +0300 Subject: [PATCH] docs: design spec and implementation plan for bridge.lbug and gRPC canonical ID Design spec (12 rounds of review): - Bridge.lbug: LadybugDB storage replacing contracts.json - gRPC canonical ID: proto-aware extraction with wildcard matching - Direction-dependent Cypher queries for cross-impact - Write-to-temp-then-rename atomic swap strategy - Backward compatibility via openBridgeOrFallback Implementation plan (10 tasks, TDD): - Bridge DB core, schema, write/read lifecycle - Proto map, source scanner resolution, confidence adjustments - Wildcard matching, sync migration, service layer integration Co-Authored-By: Claude Opus 4.6 (1M context) --- ...26-04-04-bridge-lbug-grpc-normalization.md | 1328 +++++++++++++++++ ...3-bridge-lbug-grpc-normalization-design.md | 922 ++++++++++++ 2 files changed, 2250 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-04-bridge-lbug-grpc-normalization.md create mode 100644 docs/superpowers/specs/2026-04-03-bridge-lbug-grpc-normalization-design.md diff --git a/docs/superpowers/plans/2026-04-04-bridge-lbug-grpc-normalization.md b/docs/superpowers/plans/2026-04-04-bridge-lbug-grpc-normalization.md new file mode 100644 index 000000000..1cfe3675e --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-bridge-lbug-grpc-normalization.md @@ -0,0 +1,1328 @@ +# Bridge.lbug & gRPC Canonical ID Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate contract storage from `contracts.json` to LadybugDB (`bridge.lbug`) and fix gRPC normalization mismatch via proto-aware extraction. + +**Architecture:** Two independent components — (1) bridge.lbug: new `bridge-db.ts` module for LadybugDB lifecycle, `bridge-schema.ts` for DDL, consumer migration in storage/service/cross-impact/sync/CLI; (2) gRPC: `buildProtoMap()` + `resolveProtoConflict()` in grpc-extractor, `serviceContractId()`, wildcard matching in matching.ts. Both integrate at `sync.ts` where matching → write happens. + +**Tech Stack:** TypeScript, LadybugDB (DuckDB-based graph DB), Vitest, Node.js `node:crypto` for SHA-256. + +**Spec:** [`docs/superpowers/specs/2026-04-03-bridge-lbug-grpc-normalization-design.md`](../specs/2026-04-03-bridge-lbug-grpc-normalization-design.md) + +--- + +## File Map + +### New Files +| File | Responsibility | +|------|---------------| +| `src/core/group/bridge-schema.ts` | DDL constants for Contract, RepoSnapshot, ContractLink tables; `BRIDGE_SCHEMA_VERSION` | +| `src/core/group/bridge-db.ts` | `openBridgeDb`, `ensureBridgeSchema`, `writeBridge`, `queryBridge`, `closeBridgeDb`, `openBridgeDbReadOnly`, `bridgeExists`, `readBridgeMeta`, `writeBridgeMeta`, `retryRename`, `contractNodeId` | +| `test/unit/group/bridge-db.test.ts` | Unit tests for bridge-db.ts | +| `test/integration/group/bridge-sync.test.ts` | Integration tests for bridge.lbug through syncGroup | + +### Modified Files +| File | What Changes | +|------|-------------| +| `src/core/group/types.ts` | Add `BridgeHandle`, `BridgeMeta`, `LegacyContractRegistry`; add `'wildcard'` to `MatchType` | +| `src/core/group/storage.ts` | Remove `writeContractRegistry`, `readContractRegistry`, `CONTRACTS_FILE`; keep as `readContractRegistryJson` (private); add `openBridgeOrFallback` (imports from bridge-db.ts) | +| `src/core/group/matching.ts` | Export `buildProviderIndex`; `runExactMatch` skips gRPC `/*`; add `runWildcardMatch` | +| `src/core/group/extractors/grpc-extractor.ts` | Add `buildProtoMap`, `resolveProtoConflict`, `serviceContractId`; modify 4 source scanners | +| `src/core/group/sync.ts` | Replace `writeContractRegistry` with `writeBridge`; add wildcard pass | +| `src/core/group/cross-impact.ts` | New `runGroupImpact` with `bridgeQuery`; rename old to `runGroupImpactLegacy` | +| `src/core/group/service.ts` | Use `openBridgeOrFallback`; extend `crossImpactFn` with hint | +| `src/cli/group.ts` | Update sync/impact/status commands | +| `src/mcp/tools.ts` | Update tool descriptions (remove `contracts.json` references) | +| `src/mcp/local/local-backend.ts` | Update `groupImpact`, `groupContracts` | +| `test/unit/group/matching.test.ts` | Add wildcard match tests | +| `test/unit/group/grpc-extractor.test.ts` | Add proto map + canonical ID tests | +| `test/unit/group/cross-impact.test.ts` | Direction-dependent Cypher, ref fallback, hint | +| `test/unit/group/sync.test.ts` | Update for bridge.lbug + wildcard pass | +| `test/unit/group/service.test.ts` | Update for openBridgeOrFallback | +| `test/unit/group/storage.test.ts` | Update for removed functions | +| `test/unit/tools.test.ts` | Update tool description assertions if any | +| `test/integration/group/group-impact.test.ts` | Update for bridge.lbug | + +--- + +## Task 1: Types & Schema Foundation + +**Files:** +- Modify: `gitnexus/src/core/group/types.ts` +- Create: `gitnexus/src/core/group/bridge-schema.ts` + +- [ ] **Step 1: Add new types to `types.ts`** + +At the top of `gitnexus/src/core/group/types.ts`, after the existing `MatchType`: + +```typescript +// Line 2: update MatchType +export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding'; +``` + +At the end of `types.ts`, after `OutOfScopeLink`: + +```typescript +/** + * @deprecated Use bridge.lbug instead. Kept for JSON fallback during migration. + * This is a type alias — ContractRegistry is NOT removed yet. + * In Task 10 (cleanup), ContractRegistry will be renamed to LegacyContractRegistry + * and all imports updated. For now, both names work. + */ +export type LegacyContractRegistry = ContractRegistry; + +/** 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[]; +} +``` + +- [ ] **Step 2: Create `bridge-schema.ts`** + +Create `gitnexus/src/core/group/bridge-schema.ts`: + +```typescript +/** + * Bridge LadybugDB schema for cross-repo Contract Registry. + * Separate from per-repo schema in lbug/schema.ts. + */ + +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, +]; +``` + +- [ ] **Step 3: Verify build** + +Run: `cd gitnexus && npm run build` +Expected: Clean build, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add gitnexus/src/core/group/types.ts gitnexus/src/core/group/bridge-schema.ts +git commit -m "feat(group): add BridgeHandle/BridgeMeta types and bridge schema DDL" +``` + +--- + +## Task 2: Bridge DB Core — Open, Schema, Query, Close + +**Files:** +- Create: `gitnexus/src/core/group/bridge-db.ts` +- Create: `gitnexus/test/unit/group/bridge-db.test.ts` + +- [ ] **Step 1: Write failing tests for open/schema/query/close** + +Create `gitnexus/test/unit/group/bridge-db.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + openBridgeDb, + ensureBridgeSchema, + queryBridge, + closeBridgeDb, +} from '../../../src/core/group/bridge-db.js'; + +describe('bridge-db core', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bridge-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_openBridgeDb_creates_file_and_closes', async () => { + const dbPath = path.join(tmpDir, 'test.lbug'); + const handle = await openBridgeDb(dbPath); + expect(handle).toBeDefined(); + expect(handle.groupDir).toBe(tmpDir); + await closeBridgeDb(handle); + // File should exist after close + await expect(fs.access(dbPath)).resolves.toBeUndefined(); + }); + + 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); + // Verify tables exist by inserting a dummy node + 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); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts` +Expected: FAIL — `bridge-db.js` doesn't exist. + +- [ ] **Step 3: Implement bridge-db.ts core functions** + +Create `gitnexus/src/core/group/bridge-db.ts`: + +```typescript +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import { createHash } from 'node:crypto'; +import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js'; +import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js'; + +// LadybugDB native binding — same import path as pool-adapter.ts:19 +import lbug from '@ladybugdb/core'; + +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'); +} + +export async function openBridgeDb(dbPath: string): Promise { + const parentDir = path.dirname(dbPath); + await fsp.mkdir(parentDir, { recursive: true }); + // LadybugDB constructor: (path, bufferManagerSize, enableCompression, readOnly) + // See pool-adapter.ts:265-270 for reference + 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; +} + +export async function ensureBridgeSchema(handle: BridgeHandle): Promise { + const conn = handle._conn as any; + for (const q of BRIDGE_SCHEMA_QUERIES) { + try { + await conn.query(q); + } catch (err: any) { + const msg = err?.message ?? ''; + if (!msg.includes('already exists')) throw err; + } + } +} + +export async function queryBridge( + handle: BridgeHandle, + cypher: string, + params?: Record, +): Promise { + const conn = handle._conn as any; + if (params && Object.keys(params).length > 0) { + // Parameterized query — same pattern as pool-adapter.ts:524-532 + const stmt = await conn.prepare(cypher); + if (!stmt.isSuccess()) { + const errMsg = await stmt.getErrorMessage(); + throw new Error(`Prepare failed: ${errMsg}`); + } + const queryResult = await conn.execute(stmt, params); + const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; + return (await result.getAll()) as T[]; + } + const result = await conn.query(cypher); + return (Array.isArray(result) ? await result[0].getAll() : await result.getAll()) as T[]; +} + +export async function closeBridgeDb(handle: BridgeHandle): Promise { + try { + const conn = handle._conn as any; + await conn.close(); // async — must await before renaming files on Windows + } catch { /* ignore */ } + try { + const db = handle._db as any; + await db.close(); + } catch { /* ignore */ } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts` +Expected: 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add gitnexus/src/core/group/bridge-db.ts gitnexus/test/unit/group/bridge-db.test.ts +git commit -m "feat(group): bridge-db core — open, schema, query, close" +``` + +--- + +## Task 3: Bridge DB — writeBridge, readBridgeMeta, openBridgeDbReadOnly + +**Files:** +- Modify: `gitnexus/src/core/group/bridge-db.ts` +- Modify: `gitnexus/test/unit/group/bridge-db.test.ts` + +- [ ] **Step 1: Write failing tests for writeBridge round-trip** + +Append to `gitnexus/test/unit/group/bridge-db.test.ts`: + +```typescript +import { + writeBridge, + openBridgeDbReadOnly, + readBridgeMeta, + bridgeExists, +} from '../../../src/core/group/bridge-db.js'; +import type { StoredContract, CrossLink, RepoSnapshot } from '../../../src/core/group/types.js'; + +describe('writeBridge + read', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bridge-write-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + const makeContract = (overrides: Partial = {}): StoredContract => ({ + 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, + }); + + 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_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', filePath: 'src/api.ts', 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_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([]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts` +Expected: FAIL — `writeBridge` etc. not exported. + +- [ ] **Step 3: Implement writeBridge, readBridgeMeta, openBridgeDbReadOnly, bridgeExists** + +Append to `gitnexus/src/core/group/bridge-db.ts`: + +```typescript +const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); + +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: any) { + if (!RETRY_CODES.has(err.code) || i === attempts) throw err; + await new Promise(r => setTimeout(r, 100 * Math.pow(2, i - 1))); + } + } +} + +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'); + await fsp.rename(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: [] }; + } +} + +export async function writeBridge( + groupDir: string, + data: { + contracts: StoredContract[]; + crossLinks: CrossLink[]; + repoSnapshots: Record; + missingRepos: string[]; + }, +): Promise { + const tempPath = path.join(groupDir, 'bridge.lbug.tmp'); + const finalPath = path.join(groupDir, 'bridge.lbug'); + + await fsp.rm(tempPath, { force: true }); + const tempHandle = await openBridgeDb(tempPath); + try { + await ensureBridgeSchema(tempHandle); + + // Insert contracts + for (const c of data.contracts) { + const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath); + await queryBridge(tempHandle, `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), + }); + } + + // Insert cross-links + for (const link of data.crossLinks) { + const fromId = contractNodeId( + link.from.repo, link.contractId, 'consumer', link.from.symbolRef.filePath, + ); + const toId = contractNodeId( + link.to.repo, link.contractId, 'provider', link.to.symbolRef.filePath, + ); + await queryBridge(tempHandle, ` + 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, + }); + } + + // Insert repo snapshots + for (const [repoPath, snap] of Object.entries(data.repoSnapshots)) { + await queryBridge(tempHandle, `CREATE (s:RepoSnapshot { + id: $id, indexedAt: $indexedAt, lastCommit: $lastCommit + })`, { id: repoPath, indexedAt: snap.indexedAt, lastCommit: snap.lastCommit }); + } + + await closeBridgeDb(tempHandle); + } catch (err) { + await closeBridgeDb(tempHandle).catch(() => {}); + await fsp.rm(tempPath, { force: true }); + throw err; + } + + // Atomic swap + const bakPath = path.join(groupDir, 'bridge.lbug.bak'); + await fsp.rm(bakPath, { force: true }); + try { await fsp.access(finalPath); await retryRename(finalPath, bakPath); } catch {} + await retryRename(tempPath, finalPath); + await fsp.rm(bakPath, { force: true }); + + // Write meta.json + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + missingRepos: data.missingRepos, + }); +} + +export async function openBridgeDbReadOnly(groupDir: string): Promise { + const dbPath = path.join(groupDir, 'bridge.lbug'); + try { + await fsp.access(dbPath); + } catch { + // Check for .bak recovery + const bakPath = path.join(groupDir, 'bridge.lbug.bak'); + try { + await fsp.access(bakPath); + await fsp.rename(bakPath, dbPath); + } catch { + return null; + } + } + try { + const db = new lbug.Database(dbPath, 0, false, true); // readOnly + const conn = new lbug.Connection(db); + // Version check + const meta = await readBridgeMeta(groupDir); + if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) { + conn.close(); db.close(); + return null; + } + return { _db: db, _conn: conn, groupDir } as BridgeHandle; + } catch { + return null; + } +} + +export async function bridgeExists(groupDir: string): Promise { + const handle = await openBridgeDbReadOnly(groupDir); + if (!handle) return false; + await closeBridgeDb(handle); + return true; +} + +// NOTE: openBridgeOrFallback lives in storage.ts (not bridge-db.ts) per spec. +// It uses readContractRegistryJson (private in storage.ts) for JSON fallback. +// See Task 7 Step 4 for the implementation. +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add gitnexus/src/core/group/bridge-db.ts gitnexus/test/unit/group/bridge-db.test.ts +git commit -m "feat(group): bridge-db writeBridge, readBridgeMeta, openBridgeDbReadOnly" +``` + +--- + +## Task 4: gRPC Proto Map — buildProtoMap & resolveProtoConflict + +**Files:** +- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts` +- Modify: `gitnexus/test/unit/group/grpc-extractor.test.ts` + +- [ ] **Step 1: Write failing tests for buildProtoMap** + +Add to `gitnexus/test/unit/group/grpc-extractor.test.ts` a new `describe('buildProtoMap')` block. Tests: + +- `test_buildProtoMap_single_proto_parses_package_service_methods` — create a temp dir with a `.proto` file containing `package com.example; service UserService { rpc GetUser(...) returns (...); rpc ListUsers(...) returns (...); }`, call `buildProtoMap(tmpDir)`, assert map has key `'UserService'` with one entry: `{ package: 'com.example', serviceName: 'UserService', methods: ['GetUser', 'ListUsers'], protoPath: ... }`. +- `test_buildProtoMap_no_package_declaration` — proto without `package` → `package: ''`. +- `test_buildProtoMap_no_protos_returns_empty` — empty dir → empty map. +- `test_buildProtoMap_conflicting_names` — two protos with same service name different packages → array of 2. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts -t "buildProtoMap"` +Expected: FAIL. + +- [ ] **Step 3: Implement buildProtoMap** + +Add to `gitnexus/src/core/group/extractors/grpc-extractor.ts`: + +```typescript +export interface ProtoServiceInfo { + package: string; + serviceName: string; + methods: string[]; + protoPath: string; +} + +export async function buildProtoMap(repoPath: string): Promise> { + const map = new Map(); + const protoFiles = await glob('**/*.proto', { cwd: repoPath, absolute: false, nodir: true }); + + for (const rel of protoFiles) { + const content = readSafe(repoPath, rel); + if (!content) continue; + + const pkgMatch = content.match(/^\s*package\s+([\w.]+)\s*;/m); + const pkg = pkgMatch?.[1] ?? ''; + + const serviceBlocks = extractServiceBlocks(content); + for (const block of serviceBlocks) { + const rpcRe = /rpc\s+(\w+)\s*\(/g; + const methods: string[] = []; + let m: RegExpExecArray | null; + while ((m = rpcRe.exec(block.body)) !== null) { + methods.push(m[1]); + } + const info: ProtoServiceInfo = { + package: pkg, + serviceName: block.name, + methods, + protoPath: rel, + }; + const existing = map.get(block.name) ?? []; + existing.push(info); + map.set(block.name, existing); + } + } + return map; +} +``` + +- [ ] **Step 4: Write failing tests for resolveProtoConflict** + +Tests for: single candidate → returns it; multiple → directory proximity; no candidates → null. + +- [ ] **Step 5: Implement resolveProtoConflict** + +```typescript +export function resolveProtoConflict( + serviceName: string, + sourceFilePath: string, + candidates: ProtoServiceInfo[], +): ProtoServiceInfo | null { + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + const sourceDir = path.dirname(sourceFilePath); + let best = candidates[0]; + let bestScore = 0; + for (const c of candidates) { + const protoDir = path.dirname(c.protoPath); + let shared = 0; + const min = Math.min(sourceDir.length, protoDir.length); + for (let i = 0; i < min; i++) { + if (sourceDir[i] === protoDir[i]) shared++; else break; + } + if (shared > bestScore) { bestScore = shared; best = c; } + } + return best; +} +``` + +- [ ] **Step 6: Add serviceContractId helper** + +```typescript +export function serviceContractId(pkg: string, serviceName: string): string { + const prefix = pkg ? `${pkg}.${serviceName}` : serviceName; + return `grpc::${prefix}/*`; +} +``` + +- [ ] **Step 7: Run all grpc-extractor tests** + +Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts` +Expected: All PASS. + +- [ ] **Step 8: Commit** + +```bash +git add gitnexus/src/core/group/extractors/grpc-extractor.ts gitnexus/test/unit/group/grpc-extractor.test.ts +git commit -m "feat(group): buildProtoMap, resolveProtoConflict, serviceContractId" +``` + +--- + +## Task 5: gRPC Source Scanners — Proto-Aware Resolution + +**Files:** +- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts` +- Modify: `gitnexus/test/unit/group/grpc-extractor.test.ts` + +- [ ] **Step 1: Write failing tests for proto-resolved Go provider** + +Test: given a temp dir with a `.proto` (`package com.example; service UserService { rpc GetUser... }`) and a Go source file with `RegisterUserServiceServer`, calling `extract()` should produce a contract with `contractId: 'grpc::com.example.UserService/*'` and `confidence: 0.8`. + +- [ ] **Step 2: Run test to verify it fails** + +- [ ] **Step 3: Modify `GrpcExtractor.extract()` to build protoMap and pass to scanners** + +In `GrpcExtractor.extract()`, add at the top: +```typescript +const protoMap = await buildProtoMap(repoPath); +``` + +Then pass `protoMap` to each scanner method. Modify each scanner (Go, Java, Python, TS) to accept `protoMap` as a parameter and resolve via `resolveProtoConflict()`. + +For Go provider (`scanGoProviders`): +```typescript +private scanGoProviders(content: string, filePath: string, protoMap: Map): ExtractedContract[] { + // ... existing regex ... + const serviceName = m[1]; + const candidates = protoMap.get(serviceName); + const proto = resolveProtoConflict(serviceName, filePath, candidates ?? []); + const cid = proto + ? serviceContractId(proto.package, proto.serviceName) + : serviceOnlyContractId(serviceName); + const conf = proto ? 0.8 : 0.65; + // ... push makeContract(cid, 'provider', filePath, ..., conf, ...) ... +} +``` + +Apply similar changes to Go consumer (conf: proto ? 0.75 : 0.55), Java, Python, TS scanners. + +For TS scanner — keep per-method contracts but add package: +```typescript +const proto = resolveProtoConflict(serviceName, filePath, protoMap.get(serviceName) ?? []); +const pkg = proto?.package ?? ''; +const cid = contractId(pkg, serviceName, methodName); +``` + +- [ ] **Step 4: Write test for fallback (no proto → reduced confidence)** + +Test: Go source with `RegisterFooServer` but no `.proto` file → `contractId: 'grpc::Foo/*'`, `confidence: 0.65`. + +- [ ] **Step 5: Run all grpc-extractor tests** + +Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add gitnexus/src/core/group/extractors/grpc-extractor.ts gitnexus/test/unit/group/grpc-extractor.test.ts +git commit -m "feat(group): proto-aware gRPC source scanners with confidence adjustments" +``` + +--- + +## Task 6: Matching — buildProviderIndex, runExactMatch skip, runWildcardMatch + +**Files:** +- Modify: `gitnexus/src/core/group/matching.ts` +- Modify: `gitnexus/test/unit/group/matching.test.ts` + +- [ ] **Step 1: Write failing tests for wildcard matching** + +Add tests to `gitnexus/test/unit/group/matching.test.ts`: + +- `test_runExactMatch_skips_grpc_wildcard_contracts` — consumer with `grpc::com.example.UserService/*` and provider with same → NOT matched as exact (both in unmatched). +- `test_runExactMatch_does_not_skip_http_wildcards` — HTTP wildcards still work. +- `test_runWildcardMatch_fq_service_match` — consumer `grpc::com.example.userservice/*` matches provider `grpc::com.example.userservice/GetUser`. +- `test_runWildcardMatch_bare_name_match` — consumer `grpc::userservice/*` matches provider `grpc::com.example.userservice/GetUser`. +- `test_runWildcardMatch_skips_wildcard_providers` — wildcard consumer vs wildcard provider → no match. +- `test_runWildcardMatch_confidence_min` — confidence = min(provider, consumer). +- `test_runWildcardMatch_matchType_is_wildcard` — CrossLink has `matchType: 'wildcard'`. +- `test_runWildcardMatch_contractId_is_consumers` — CrossLink has consumer's contractId. + +- [ ] **Step 2: Run tests to verify they fail** + +- [ ] **Step 3: Extract `buildProviderIndex` from `runExactMatch`** + +```typescript +export function buildProviderIndex(contracts: StoredContract[]): Map { + const providers = contracts.filter((c) => c.role === 'provider'); + const index = new Map(); + for (const p of providers) { + const key = normalizeContractId(p.contractId); + const list = index.get(key) || []; + list.push(p); + index.set(key, list); + } + return index; +} +``` + +- [ ] **Step 4: Modify `runExactMatch` to skip gRPC wildcards and accept optional index** + +```typescript +function isGrpcWildcard(contractId: string): boolean { + return contractId.startsWith('grpc::') && contractId.endsWith('/*'); +} + +export function runExactMatch( + contracts: StoredContract[], + providerIndex?: Map, +): MatchResult { + const index = providerIndex ?? buildProviderIndex(contracts); + // Filter OUT gRPC wildcard consumers from exact matching — they go to wildcard pass + const consumers = contracts.filter((c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId)); + // ... rest same as before, using `index` instead of building one ... + // normalUnmatched already excludes matched contracts. + // gRPC wildcards were never passed to exact matching, so they're NOT in normalUnmatched. + // Re-add them to unmatched for the wildcard pass. + const grpcWildcardContracts = contracts.filter((c) => isGrpcWildcard(c.contractId)); + // Dedup: normalUnmatched won't contain wildcards (they were filtered from consumers/providers) + const unmatched = [...normalUnmatched, ...grpcWildcardContracts]; + return { matched, unmatched }; +} +``` + +- [ ] **Step 5: Implement `runWildcardMatch`** + +```typescript +export function runWildcardMatch( + unmatched: StoredContract[], + providerIndex: Map, +): { matched: CrossLink[]; remaining: StoredContract[] } { + 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); + const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); + + for (const [key, providers] of providerIndex) { + if (!key.startsWith('grpc::') || key.endsWith('/*')) continue; + const afterPrefix = key.slice(6); + const slashIdx = afterPrefix.indexOf('/'); + if (slashIdx < 0) continue; + const providerFqService = afterPrefix.slice(0, slashIdx); + + const isMatch = providerFqService === fqService + || (!fqService.includes('.') && providerFqService.endsWith('.' + fqService)); + + if (!isMatch) continue; + + for (const provider of providers) { + 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, + 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 }; +} +``` + +- [ ] **Step 6: Run tests** + +Run: `cd gitnexus && npx vitest run test/unit/group/matching.test.ts` +Expected: All PASS. + +- [ ] **Step 7: Commit** + +```bash +git add gitnexus/src/core/group/matching.ts gitnexus/test/unit/group/matching.test.ts +git commit -m "feat(group): buildProviderIndex, runExactMatch gRPC skip, runWildcardMatch" +``` + +--- + +## Task 7: Sync — writeBridge + Wildcard Pass + +**Files:** +- Modify: `gitnexus/src/core/group/sync.ts` +- Modify: `gitnexus/src/core/group/storage.ts` +- Modify: `gitnexus/test/unit/group/sync.test.ts` + +- [ ] **Step 1: Write failing test for sync creating bridge.lbug** + +In `gitnexus/test/unit/group/sync.test.ts`, add test: `syncGroup` with `groupDir` option produces a `bridge.lbug` file (use `bridgeExists()`), NOT a `contracts.json`. + +- [ ] **Step 2: Run test to verify it fails** + +- [ ] **Step 3: Update sync.ts** + +Replace `writeContractRegistry` import with `writeBridge` from bridge-db. Update the matching + write section: + +```typescript +import { writeBridge } from './bridge-db.js'; +import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js'; + +// ... inside syncGroup, after extraction ... +const providerIndex = buildProviderIndex(autoContracts); +const { matched: exactLinks, unmatched } = runExactMatch(autoContracts, providerIndex); +const { matched: wildcardLinks } = runWildcardMatch(unmatched, providerIndex); +const crossLinks: CrossLink[] = [...manifestResult.crossLinks, ...exactLinks, ...wildcardLinks]; +const allContracts: StoredContract[] = [...manifestResult.contracts, ...autoContracts]; + +if (opts?.groupDir && !opts.skipWrite) { + await writeBridge(opts.groupDir, { + contracts: allContracts, + crossLinks, + repoSnapshots, + missingRepos, + }); +} +``` + +- [ ] **Step 4: Update storage.ts — add openBridgeOrFallback, keep old API temporarily** + +Do NOT remove `writeContractRegistry`/`readContractRegistry` yet — `service.ts` and `cli/group.ts` still depend on them. They will be removed in Task 10 after all consumers are migrated. + +Add `openBridgeOrFallback` to `storage.ts` (per spec — it lives here, not in bridge-db.ts, because it uses private `readContractRegistryJson`): + +```typescript +import { openBridgeDbReadOnly, closeBridgeDb, readBridgeMeta } from './bridge-db.js'; +import type { BridgeHandle, BridgeMeta, LegacyContractRegistry } from './types.js'; + +// Rename existing readContractRegistry to readContractRegistryJson (keep export temporarily) +async function readContractRegistryJson(groupDir: string): Promise { + const filePath = path.join(groupDir, CONTRACTS_FILE); + try { + const content = await fsp.readFile(filePath, 'utf-8'); + return JSON.parse(content) as LegacyContractRegistry; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +export async function openBridgeOrFallback(groupDir: string): Promise< + { type: 'bridge'; handle: BridgeHandle; meta: BridgeMeta } + | { type: 'json'; registry: LegacyContractRegistry; deprecationWarning: string } + | { type: 'none' } +> { + const handle = await openBridgeDbReadOnly(groupDir); + if (handle) { + const meta = await readBridgeMeta(groupDir); + return { type: 'bridge', handle, meta }; + } + const registry = await readContractRegistryJson(groupDir); + if (registry) { + return { + type: 'json', + registry, + deprecationWarning: 'contracts.json is deprecated. Run "gitnexus group sync " to migrate to bridge.lbug.', + }; + } + return { type: 'none' }; +} +``` + +- [ ] **Step 5: Update storage.test.ts — add openBridgeOrFallback tests** + +Add tests for bridge/json/none fallback paths. Keep existing `writeContractRegistry`/`readContractRegistry` tests (they still pass, functions not removed yet). + +- [ ] **Step 6: Run sync tests** + +Run: `cd gitnexus && npx vitest run test/unit/group/sync.test.ts` +Expected: All PASS. + +- [ ] **Step 7: Commit** + +```bash +git add gitnexus/src/core/group/sync.ts gitnexus/src/core/group/storage.ts gitnexus/test/unit/group/sync.test.ts gitnexus/test/unit/group/storage.test.ts +git commit -m "feat(group): sync writes to bridge.lbug with wildcard matching pass" +``` + +--- + +## Task 8: Cross-Impact — Cypher-Based Phase 2 + +**Files:** +- Modify: `gitnexus/src/core/group/cross-impact.ts` +- Modify: `gitnexus/test/unit/group/cross-impact.test.ts` + +- [ ] **Step 1: Write failing tests for new runGroupImpact with bridgeQuery** + +Tests for: +- Upstream direction: Cypher matches provider side, fans out to consumer repo. +- Downstream direction: Cypher matches consumer side, fans out to provider repo. +- Ref fallback: empty symbolUid still matches via filePath+symbolName. +- Subgroup filtering on fan-out side. +- Confidence ordering (high-confidence links processed first). + +- [ ] **Step 2: Run tests to verify they fail** + +- [ ] **Step 3: Rename current `runGroupImpact` → `runGroupImpactLegacy`** + +Keep the current function unchanged but renamed. Export both. + +- [ ] **Step 4: Implement new `runGroupImpact` with `bridgeQuery`** + +Update `GroupImpactOptions` interface in `cross-impact.ts`: +```typescript +export interface GroupImpactOptions { + groupName: string; + target: string; + repoPath: string; + direction: 'upstream' | 'downstream'; + bridgeQuery: (cypher: string, params: Record) => Promise; + localImpactFn: (target: string, direction: string) => Promise; + crossImpactFn: ( + targetGroupPath: string, + symbolUid: string, + direction: string, + hint?: { filePath: string; symbolName: string }, + ) => Promise; + maxDepth?: number; + minConfidence?: number; + subgroup?: string; + timeout?: number; + crossDepth?: number; +} +``` + +Phase 2 loop must pass `hint` when `fanOutUid` is empty: +```typescript +for (const row of rows) { + if (Date.now() > wallDeadline) { truncated = true; break; } + if (crossDepth < 1) break; + + // Pass hint for name-based fallback when UID is empty (gRPC contracts) + const hint = row.fanOutUid + ? undefined + : { filePath: row.fanOutFilePath, symbolName: row.fanOutSymbolName }; + const remote = await opts.crossImpactFn(row.fanOutRepo, row.fanOutUid, opts.direction, hint); + + // Guard: impact() returns { error: ... } on not-found (truthy but not a real result) + if (remote && typeof remote === 'object' && !('error' in (remote as Record))) { + // ... count as successful fan-out ... + } +} +``` + +Key constants: +```typescript +const UPSTREAM_QUERY = ` +MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract) +WHERE provider.repo = $sourceRepo + AND (provider.symbolUid IN $localUids + OR (NOT provider.symbolUid IN $localUids AND (provider.filePath + '::' + provider.symbolName) IN $localRefs)) + AND l.confidence >= $minConfidence + AND ($subgroup IS NULL OR consumer.repo = $subgroup OR consumer.repo STARTS WITH $subgroup + '/') +RETURN consumer.repo AS fanOutRepo, consumer.symbolUid AS fanOutUid, + consumer.filePath AS fanOutFilePath, consumer.symbolName AS fanOutSymbolName, + provider.symbolUid AS matchedLocalUid, + l.matchType AS matchType, l.confidence AS confidence, l.contractId AS contractId +ORDER BY l.confidence DESC`; + +const DOWNSTREAM_QUERY = `...`; // mirror with consumer/provider swapped +``` + +- [ ] **Step 5: Run tests** + +Run: `cd gitnexus && npx vitest run test/unit/group/cross-impact.test.ts` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add gitnexus/src/core/group/cross-impact.ts gitnexus/test/unit/group/cross-impact.test.ts +git commit -m "feat(group): Cypher-based cross-impact with direction-dependent queries" +``` + +--- + +## Task 9: Service Layer — openBridgeOrFallback Integration + +**Files:** +- Modify: `gitnexus/src/core/group/service.ts` +- Modify: `gitnexus/test/unit/group/service.test.ts` + +- [ ] **Step 1: Write failing tests** + +Tests for `groupImpact`: +- With bridge.lbug → uses new `runGroupImpact` with `bridgeQuery`. +- With JSON fallback → uses `runGroupImpactLegacy` with registry. +- With no data → returns error. +- `crossImpactFn` hint param: empty UID → name-based fallback; error-object guard. + +Tests for `groupStatus`: +- With bridge → reads meta.json + RepoSnapshot Cypher query. +- With JSON → reads from legacy registry. + +Tests for `groupContracts`: +- With bridge → Cypher query with type/repo filters. + +- [ ] **Step 2: Run tests to verify they fail** + +- [ ] **Step 3: Update service.ts** + +Import `openBridgeOrFallback`, `queryBridge`, `closeBridgeDb`, `readBridgeMeta` from `bridge-db.ts`. Update `groupImpact()`, `groupContracts()`, `groupStatus()` to use `openBridgeOrFallback` with bridge/json/none branching. + +Extend `crossImpactFn` with `hint` parameter and error-object guard (as specified in the design). + +- [ ] **Step 4: Run tests** + +Run: `cd gitnexus && npx vitest run test/unit/group/service.test.ts` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add gitnexus/src/core/group/service.ts gitnexus/test/unit/group/service.test.ts +git commit -m "feat(group): service layer uses openBridgeOrFallback with bridge/json/none" +``` + +--- + +## Task 10: CLI, MCP Tools, Remaining Tests & Cleanup + +**Files:** +- Modify: `gitnexus/src/cli/group.ts` +- Modify: `gitnexus/src/mcp/tools.ts` +- Modify: `gitnexus/src/mcp/local/local-backend.ts` +- Modify: `gitnexus/test/unit/tools.test.ts` +- Modify: `gitnexus/test/integration/group/group-impact.test.ts` +- Create: `gitnexus/test/integration/group/bridge-sync.test.ts` + +- [ ] **Step 1: Update CLI group.ts** + +Update `sync` command: remove `readContractRegistry` references. The sync command calls `syncGroup` which now writes to bridge.lbug internally. + +Update `impact` command: use `openBridgeOrFallback` check instead of `readContractRegistry`. Print deprecation warning from result if JSON fallback. + +Update `status` command: similar migration. + +- [ ] **Step 2: Update MCP tool descriptions** + +In `gitnexus/src/mcp/tools.ts`: + +```typescript +// group_sync description: +'Rebuild the Contract Registry (bridge.lbug) for a group: extract HTTP/gRPC/topic contracts, apply manifest links, exact-match and wildcard cross-links.' + +// group_contracts description: +'Inspect contracts and cross-links from the group bridge graph.' +``` + +- [ ] **Step 3: Update local-backend.ts (verify pass-through)** + +`groupImpact` and `groupContracts` in `local-backend.ts` already delegate to `GroupService` (lines 2469, 2473). Verify they don't directly use `readContractRegistry` — if not, no changes needed here. + +- [ ] **Step 4: Cleanup storage.ts — remove old API** + +Now that all consumers (sync, service, CLI) use the new bridge path: +- Remove `writeContractRegistry` (public export) +- Remove `readContractRegistry` (public export) — `readContractRegistryJson` (private) remains for fallback +- Remove `CONTRACTS_FILE` constant +- Rename `ContractRegistry` to `LegacyContractRegistry` in `types.ts` (the alias added in Task 1 becomes the only name; update all imports in cross-impact.ts, storage.ts, service.ts) +- Update `storage.test.ts`: remove tests for deleted functions, add/keep tests for `openBridgeOrFallback` +- Update `test/unit/group/types.test.ts` if it asserts on `ContractRegistry` name + +- [ ] **Step 5: Write bridge-sync integration test** + +Create `gitnexus/test/integration/group/bridge-sync.test.ts` with end-to-end test: create group config → sync → verify bridge.lbug exists → query contracts via Cypher → verify cross-links. + +- [ ] **Step 6: Update existing integration tests** + +Update `test/integration/group/group-impact.test.ts` to work with bridge.lbug instead of contracts.json. + +- [ ] **Step 7: Run full test suite** + +Run: `cd gitnexus && npx vitest run test/unit/group/ test/unit/tools.test.ts test/integration/group/` +Expected: All PASS. + +- [ ] **Step 8: Build check** + +Run: `cd gitnexus && npm run build` +Expected: Clean build. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(group): CLI/MCP cleanup, old API removal, integration tests" +``` + +--- + +## Execution Notes + +### LadybugDB API Reference (from pool-adapter.ts) +- **Import:** `import lbug from '@ladybugdb/core'` (pool-adapter.ts:19) +- **Constructor:** `new lbug.Database(path, bufferManagerSize, enableCompression, readOnly)` — 4 positional args (pool-adapter.ts:265-270) +- **Writable:** `new lbug.Database(path, 0, false, false)` +- **Read-only:** `new lbug.Database(path, 0, false, true)` +- **Parameterized query:** `const stmt = await conn.prepare(cypher); stmt.isSuccess(); const result = await conn.execute(stmt, params)` (pool-adapter.ts:524-532) +- **Result extraction:** `const rows = await result.getAll()` (pool-adapter.ts:531) +- **stdout suppression:** pool-adapter uses `silenceStdout()`/`restoreStdout()` around DB operations — bridge-db should do the same if LadybugDB prints warnings + +### DDL Syntax +- Follow `schema.ts` pattern: backtick-wrapped template literals, `PRIMARY KEY (id)` as separate clause (not inline), try/catch for "already exists" errors instead of `IF NOT EXISTS` +- Verify actual DDL syntax works during Task 1 Step 3 (build check) + +### Known Issues to Address During Implementation +- **CrossLink dedup:** If proto+source contracts create duplicate CrossLinks (same `from.repo, to.repo, contractId`), dedup in `writeBridge` before inserting +- **gRPC symbolName quality:** When passing `hint.symbolName` for fan-out, strip technical prefixes (`Register`, `New`, `Server`, `Client`, `Stub`) to get the bare service name for `impact()` resolution. Add a `stripGrpcPrefix(name: string)` helper in grpc-extractor.ts +- **`BridgeHandle` typing:** `_db` and `_conn` are `unknown` in the interface (opaque). Cast to `any` in bridge-db.ts internally. If better typing is needed, import `lbug.Database`/`lbug.Connection` types +- **`LegacyContractRegistry` imports:** Update imports in `cross-impact.ts`, `storage.ts`, `service.ts` to use `LegacyContractRegistry` instead of `ContractRegistry` +- **`fromRepo`/`toRepo` denormalization:** Included in schema but not used by current Cypher queries. Keep for now; remove if not needed after all tests pass +- **`readSafe` reuse:** `buildProtoMap` uses `readSafe` which is module-level in grpc-extractor.ts (not exported). This is fine since `buildProtoMap` lives in the same file +- **`groupStatus` contractsStale:** Must query `RepoSnapshot` nodes from bridge.lbug and compare `indexedAt` with per-repo `meta.json`. Implement in Task 9 service layer +- **Integration test fixtures:** Use existing `test/fixtures/group/test-monorepo/` for bridge-sync integration tests +- **Validation command:** Use `npm run build` (which runs `node scripts/build.js` including tsc) for build validation +- **Cypher RETURN completeness:** Ensure both Cypher queries return `matchedLocalFilePath` and `matchedLocalSymbolName` per spec (Task 8) +- **`local-backend.ts` may need no changes:** `groupImpact`/`groupContracts` already delegate to `GroupService` (lines 2469, 2473). Verify during Task 10 +- **Test coverage gaps vs spec:** Spec lists corrupted bridge, fallback precedence, read-only rejection scenarios. Ensure bridge-db.test.ts covers all of them (Task 3 tests partially cover; add missing during Task 10) +- **`types.test.ts` updates:** If any tests assert on `ContractRegistry` name, update to `LegacyContractRegistry` during Task 10 cleanup +- **UX standardization:** All commands return `{ error: "No contract data. Run 'gitnexus group sync '." }` when no data source — implement consistently in Task 9 (service layer) diff --git a/docs/superpowers/specs/2026-04-03-bridge-lbug-grpc-normalization-design.md b/docs/superpowers/specs/2026-04-03-bridge-lbug-grpc-normalization-design.md new file mode 100644 index 000000000..d72c3724c --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-bridge-lbug-grpc-normalization-design.md @@ -0,0 +1,922 @@ +# Design: Bridge.lbug Storage & gRPC Canonical ID Normalization + +**Date:** 2026-04-03 +**PR:** #606 (cross-repo impact analysis via repository groups) +**Trigger:** Review feedback from abhigyanpatwari on PR #626 ([review](https://github.com/abhigyanpatwari/GitNexus/pull/626#pullrequestreview-4055362547)) +**Status:** Draft + +## Context + +PR #606 adds cross-repo impact analysis (`group_impact`) using a Contract Registry stored as `contracts.json`. The PR #626 reviewer requested two changes before merge: + +1. **Contract storage → bridge.lbug**: The virtual bridge graph needs Cypher-queryable edges for cross-repo impact traversal instead of static JSON. +2. **gRPC normalization mismatch**: `grpc::ServiceName/*` (from source code scanners) vs `grpc::pkg.Service/Method` (from .proto files) normalize differently, causing silent matching failures in cross-repo scenarios. + +## Component 1: Bridge.lbug — Contract Storage in LadybugDB + +### Architecture + +One writable LadybugDB per group at `groups//bridge.lbug`. This DB is a **single file** (not a directory — LadybugDB/DuckDB uses file-based storage; see `lbug-adapter.ts:140`). It contains the Contract Registry as a queryable graph — contracts as nodes, cross-links as edges. + +``` +groups/ + my-group/ + group.yaml # Group config (unchanged) + bridge.lbug # LadybugDB file (replaces contracts.json) + meta.json # Bridge metadata: { version, generatedAt, missingRepos } +``` + +### Schema + +New tables in bridge.lbug (separate from per-repo schema in `schema.ts`): + +**Node tables:** + +```sql +CREATE NODE TABLE IF NOT EXISTS Contract ( + id STRING PRIMARY KEY, -- full SHA-256 hex (64 chars) of "{repo}\0{contractId}\0{role}\0{filePath}" + contractId STRING, -- "http::GET /api/users", "grpc::pkg.Svc/Method" + type STRING, -- "http" | "grpc" | "topic" | "lib" | "custom" + role STRING, -- "provider" | "consumer" + repo STRING, -- repo path within group (e.g. "hr/hiring/backend") + service STRING DEFAULT '', -- service boundary within monorepo (from StoredContract.service, set by assignService() in sync.ts) + symbolUid STRING DEFAULT '', + filePath STRING DEFAULT '', + symbolName STRING DEFAULT '', + confidence DOUBLE DEFAULT 0.0, + meta STRING DEFAULT '{}' -- JSON-serialized Record +); + +CREATE NODE TABLE IF NOT EXISTS RepoSnapshot ( + id STRING PRIMARY KEY, -- repo path within group (e.g. "hr/hiring/backend") + indexedAt STRING DEFAULT '', + lastCommit STRING DEFAULT '' +); +``` + +**Relation table:** + +```sql +CREATE REL TABLE IF NOT EXISTS ContractLink ( + FROM Contract TO Contract, + matchType STRING, -- "exact" | "manifest" | "wildcard" | "bm25" | "embedding" + confidence DOUBLE, + contractId STRING, -- consumer's contractId (consistent with runExactMatch which stores consumer.contractId) + fromRepo STRING, -- denormalized source repo for index-only lookups + toRepo STRING -- denormalized target repo for index-only lookups +); +``` + +`fromRepo` / `toRepo` are denormalized onto ContractLink to avoid expensive JOINs when filtering by repo. + +**Bridge metadata** is stored as `meta.json` in the group directory (alongside `bridge.lbug`). Contains: + +```typescript +interface BridgeMeta { + version: number; // BRIDGE_SCHEMA_VERSION from bridge-schema.ts + // On version mismatch: writeBridge() recreates from scratch (no ALTER TABLE). + // openBridgeDbReadOnly() checks version; returns null if incompatible. + generatedAt: string; // ISO timestamp of last sync + missingRepos: string[]; // repos that failed to sync (preserved for groupStatus) +} +``` + +`missingRepos` is stored in `meta.json` rather than as DB nodes because it's metadata about the sync process, not queryable graph data. `groupStatus()` reads it from `meta.json` (matching the current behavior where it reads `registry.missingRepos`). + +### Contract Primary Key + +The `Contract.id` uses a **full SHA-256 hash** (64 hex chars) to avoid both delimiter collisions and birthday-problem collisions. Contract IDs contain `::` and `/` which make composite string keys ambiguous. + +```typescript +import { createHash } from 'node:crypto'; + +function contractNodeId(repo: string, contractId: string, role: string, filePath: string): string { + return createHash('sha256') + .update(`${repo}\0${contractId}\0${role}\0${filePath}`) + .digest('hex'); // full 64 hex chars — no truncation +} +``` + +**Why full hash:** LadybugDB PRIMARY KEY is the only uniqueness enforcement mechanism (no UNIQUE constraints on columns). A truncated hash risks silent overwrites on collision. Full SHA-256 makes collisions astronomically improbable. + +**Why `filePath` in the hash:** A proto-file contract (`filePath: "proto/user.proto"`) and a source-resolved contract (`filePath: "src/server.go"`) for the same `contractId` + `role` + `repo` are **different Contract nodes**. Both are stored — matching works by `contractId`, not by `id`. See "No Dedup Between Proto and Source" below. + +### New Module: `bridge-db.ts` + +Location: `gitnexus/src/core/group/bridge-db.ts` + +**Public API:** + +```typescript +/** + * Open or create a LadybugDB at the given file path (writable mode). + * Used internally by writeBridge() for temp DB. Not typically called by consumers. + */ +export async function openBridgeDb(dbPath: string): Promise; + +/** Apply schema (CREATE TABLE IF NOT EXISTS). Idempotent. */ +export async function ensureBridgeSchema(handle: BridgeHandle): Promise; + +/** + * Write contract data to a new bridge.lbug, then atomically swap it into place. + * Creates a temporary DB at bridge.lbug.tmp, inserts all data, then renames + * tmp → final. If insertion fails, the existing bridge.lbug is untouched. + */ +export async function writeBridge( + groupDir: string, + data: { + contracts: StoredContract[]; + crossLinks: CrossLink[]; + repoSnapshots: Record; + missingRepos: string[]; + }, +): Promise; + +/** Execute a read query against the bridge graph. */ +export async function queryBridge( + handle: BridgeHandle, + cypher: string, + params?: Record, +): Promise; + +/** Close the bridge DB connection. Must be called after openBridgeDbReadOnly(). */ +export async function closeBridgeDb(handle: BridgeHandle): Promise; + +/** + * Open bridge.lbug in read-only mode (for MCP/CLI reads). + * Returns null if file is missing or corrupt (wraps open in try/catch). + * Caller MUST call closeBridgeDb() when done. + * + * Usage (read path): + * const handle = await openBridgeDbReadOnly(groupDir); + * if (!handle) { /* fallback */ } + * const rows = await queryBridge(handle, cypher, params); + * await closeBridgeDb(handle); + */ +export async function openBridgeDbReadOnly(groupDir: string): Promise; + +/** + * Check if bridge.lbug exists and is openable. + * Delegates to openBridgeDbReadOnly + closeBridgeDb. + */ +export async function bridgeExists(groupDir: string): Promise; +``` + +**BridgeHandle** is an opaque wrapper around a LadybugDB `Database` + `Connection`, similar to how `pool-adapter.ts` manages per-repo DBs but simpler (single connection, no pool needed — bridge writes are sequential during sync, reads are single-query). + +**Lifecycle clarification:** +- **Write path:** `writeBridge(groupDir, data)` — manages its own DB lifecycle internally (open temp → write → close → rename). Callers don't need open/close. +- **Read path:** `openBridgeDbReadOnly()` + `queryBridge()` + `closeBridgeDb()` — callers manage the handle. This is used by `groupContracts()`, `groupImpact()`, etc. + +### Transaction Safety + +`writeBridge()` uses a **write-to-temp-then-rename** strategy. DuckDB/LadybugDB does not support rollback of DDL operations (DROP/CREATE TABLE), so we cannot rely on database transactions for atomic replacement. Since LadybugDB stores databases as single files, `fs.rename` is an atomic file operation on POSIX and near-atomic on Windows. + +**Strategy:** + +```typescript +async function writeBridge(groupDir: string, data: ...): Promise { + const tempPath = path.join(groupDir, 'bridge.lbug.tmp'); + const finalPath = path.join(groupDir, 'bridge.lbug'); + + // 1. Write to a temporary bridge DB file + await fs.rm(tempPath, { force: true }); + const tempHandle = await openBridgeDb(tempPath); + try { + await ensureBridgeSchema(tempHandle); + // Bulk insert Contract nodes (COPY or individual inserts) + // Bulk insert ContractLink edges + // Insert RepoSnapshot nodes + await closeBridgeDb(tempHandle); + } catch (err) { + await closeBridgeDb(tempHandle).catch(() => {}); + await fs.rm(tempPath, { force: true }); + throw err; + } + + // 2. Atomic swap: rename temp → final (move old to .bak, rename temp, remove .bak) + // retryRename() handles Windows EBUSY/EPERM/EACCES with exponential backoff. + const bakPath = path.join(groupDir, 'bridge.lbug.bak'); + await fs.rm(bakPath, { force: true }); + try { await fs.access(finalPath); await retryRename(finalPath, bakPath); } catch {} + await retryRename(tempPath, finalPath); + await fs.rm(bakPath, { force: true }); + + // 3. Write meta.json via atomic temp-file rename (meta.json.tmp → meta.json) + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: new Date().toISOString(), + missingRepos: data.missingRepos, + }); +} + +/** Rename with retry for Windows file-locking errors. */ +const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); +async function retryRename(src: string, dst: string, attempts = 3): Promise { + for (let i = 1; i <= attempts; i++) { + try { await fs.rename(src, dst); return; } catch (err: any) { + if (!RETRY_CODES.has(err.code) || i === attempts) throw err; + await new Promise(r => setTimeout(r, 100 * Math.pow(2, i - 1))); + } + } +} +``` + +**Guarantees:** +- If insertion fails, `bridge.lbug.tmp` is cleaned up; `bridge.lbug` is untouched +- The rename sequence (old→bak, tmp→final, rm bak) minimizes the window where neither exists +- Windows EBUSY/EPERM/EACCES: `retryRename()` retries 3 times with exponential backoff (100ms, 200ms, 400ms) +- If crash occurs between renames: `bridge.lbug.bak` exists and can be restored manually; `openBridgeDbReadOnly()` checks for `.bak` as a recovery hint +- meta.json is written last via atomic temp-file rename (`meta.json.tmp` → `meta.json`); if meta write fails after successful DB swap, data is fresh but `generatedAt` and `missingRepos` are stale — next sync fixes both +- If EBUSY/EPERM/EACCES persists after 3 retries, sync fails with an explicit error suggesting to close MCP readers and retry + +**Corruption recovery:** If `bridge.lbug` exists but is unreadable, `openBridgeDbReadOnly()` returns `null`. Callers treat this the same as "no bridge" and follow the backward compatibility flow (see below). + +### Lifecycle + +**Write path** (`group sync`): +1. `syncGroup()` extracts contracts and runs matching (unchanged) +2. Instead of `writeContractRegistry(groupDir, registry)` → calls `writeBridge(groupDir, data)` +3. `writeBridge()` manages its own DB lifecycle internally (open temp → write → close → rename) + +**Read path** (`group_impact`, `group_contracts`, `group_status`): +1. Open bridge.lbug in read-only mode +2. Execute Cypher queries +3. Close when done + +### Consumer Migration + +| Consumer | Before | After | +|----------|--------|-------| +| `cross-impact.ts: runGroupImpact()` | Iterates `registry.crossLinks[]` in JS | Cypher query against bridge.lbug | +| `service.ts: groupContracts()` | `readContractRegistry()` → filter in JS | Cypher: `MATCH (c:Contract) WHERE c.type = $type RETURN ...`. Note: pre-existing bug where `--unmatched` uses `consumer.contractId` to check providers — out of scope for this PR but noted for follow-up. | +| `service.ts: groupImpact()` | Passes `registry` object | Passes `BridgeHandle` (or bridge executor fn) | +| `service.ts: groupStatus()` | `readContractRegistry()` for generatedAt + missingRepos + repoSnapshots | Uses `openBridgeOrFallback()`: bridge → reads `meta.json` + Cypher `MATCH (s:RepoSnapshot) RETURN s`; json → reads from `LegacyContractRegistry`; none → returns empty status | +| `cli/group.ts: status` | `readContractRegistry()` | Uses service layer (unchanged CLI output) | +| `cli/group.ts: impact` | Checks `readContractRegistry()` | Uses `openBridgeOrFallback()` (see backward compat) | + +### Deleted / Renamed Code + +- `storage.ts`: `writeContractRegistry()`, `readContractRegistry()` removed (public API); `CONTRACTS_FILE` removed. `readContractRegistryJson()` kept as private fallback. +- `types.ts`: `ContractRegistry` **renamed to `LegacyContractRegistry`** (not deleted). It's still used by: `cross-impact.ts` (JSON fallback path in `openBridgeOrFallback`), `storage.ts` (private `readContractRegistryJson`), and potentially `service.ts` (backward compat). All imports updated to use the new name. The type is marked `@deprecated`. +- `contracts.json` files no longer created by `group sync` + +### Cross-Impact: New `GroupImpactOptions` Interface + +The current `GroupImpactOptions` has `registry: ContractRegistry` for JS iteration. After migration, it receives a bridge query function instead: + +```typescript +export interface GroupImpactOptions { + groupName: string; + target: string; + repoPath: string; + direction: 'upstream' | 'downstream'; + // CHANGED: replaces `registry: ContractRegistry` + bridgeQuery: (cypher: string, params: Record) => Promise; + localImpactFn: (target: string, direction: string) => Promise; + crossImpactFn: ( + targetGroupPath: string, + symbolUid: string, + direction: string, + hint?: { filePath: string; symbolName: string }, + ) => Promise; + maxDepth?: number; + minConfidence?: number; + subgroup?: string; + timeout?: number; + crossDepth?: number; +} +``` + +**How the caller connects bridge to `runGroupImpact`** (in `service.ts: groupImpact()`): + +```typescript +const result = await openBridgeOrFallback(groupDir); +if (result.type === 'none') return { error: 'Run group_sync first.' }; +if (result.type === 'json') { + // Legacy path: current runGroupImpact is renamed to runGroupImpactLegacy + // and preserved unchanged (accepts `registry: LegacyContractRegistry`). + // New runGroupImpact accepts `bridgeQuery`. + return runGroupImpactLegacy({ ...opts, registry: result.registry }); +} +// Bridge path: +const handle = result.handle; +try { + return await runGroupImpact({ + ...opts, + bridgeQuery: (cypher, params) => queryBridge(handle, cypher, params), + }); +} finally { + await closeBridgeDb(handle); +} +``` + +**How `runGroupImpact` builds Cypher parameters from Phase 1 results:** + +```typescript +// After Phase 1 local impact: +const uids = collectPhase1Uids(local); // Set of symbol IDs +const phase1Refs = collectPhase1Refs(local); // Set of "filePath::symbolName" + +// Normalize subgroup before passing to Cypher +const normalizedSubgroup = opts.subgroup?.trim().replace(/\/+$/, '') || null; + +// Phase 2: Execute direction-dependent Cypher query +interface CrossImpactRow { + fanOutRepo: string; fanOutUid: string; fanOutFilePath: string; fanOutSymbolName: string; + matchedLocalUid: string; matchedLocalFilePath: string; matchedLocalSymbolName: string; + matchType: string; confidence: number; contractId: string; +} +const rows = await opts.bridgeQuery( + direction === 'upstream' ? UPSTREAM_QUERY : DOWNSTREAM_QUERY, + { + sourceRepo: opts.repoPath, + localUids: [...uids], + localRefs: [...phase1Refs], + minConfidence: opts.minConfidence ?? 0.5, + subgroup: normalizedSubgroup, + }, +); +``` + +### Cross-Impact Cypher Queries + +Phase 2 of `runGroupImpact()` needs **two direction-dependent queries** to preserve the current upstream/downstream semantics from `cross-impact.ts:142-161`. + +**Upstream query** (direction = 'upstream'): "I'm changing this symbol — who consumes it?" +The local impact found symbols in the source repo. We look for cross-links where the **target** (provider) matches a local symbol, and fan out to the **source** (consumer) side: + +```cypher +MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract) +WHERE provider.repo = $sourceRepo + AND (provider.symbolUid IN $localUids + OR (NOT provider.symbolUid IN $localUids AND (provider.filePath + '::' + provider.symbolName) IN $localRefs)) + AND l.confidence >= $minConfidence + AND ($subgroup IS NULL OR consumer.repo = $subgroup OR consumer.repo STARTS WITH $subgroup + '/') +RETURN consumer.repo AS fanOutRepo, + consumer.symbolUid AS fanOutUid, + consumer.filePath AS fanOutFilePath, + consumer.symbolName AS fanOutSymbolName, + provider.symbolUid AS matchedLocalUid, + provider.filePath AS matchedLocalFilePath, + provider.symbolName AS matchedLocalSymbolName, + l.matchType AS matchType, + l.confidence AS confidence, + l.contractId AS contractId +ORDER BY l.confidence DESC +``` + +**Downstream query** (direction = 'downstream'): "I'm changing this symbol — what does it consume?" +The local impact found symbols in the source repo. We look for cross-links where the **source** (consumer) matches a local symbol, and fan out to the **target** (provider) side: + +```cypher +MATCH (consumer:Contract)-[l:ContractLink]->(provider:Contract) +WHERE consumer.repo = $sourceRepo + AND (consumer.symbolUid IN $localUids + OR (NOT consumer.symbolUid IN $localUids AND (consumer.filePath + '::' + consumer.symbolName) IN $localRefs)) + AND l.confidence >= $minConfidence + AND ($subgroup IS NULL OR provider.repo = $subgroup OR provider.repo STARTS WITH $subgroup + '/') +RETURN provider.repo AS fanOutRepo, + provider.symbolUid AS fanOutUid, + provider.filePath AS fanOutFilePath, + provider.symbolName AS fanOutSymbolName, + consumer.symbolUid AS matchedLocalUid, + consumer.filePath AS matchedLocalFilePath, + consumer.symbolName AS matchedLocalSymbolName, + l.matchType AS matchType, + l.confidence AS confidence, + l.contractId AS contractId +ORDER BY l.confidence DESC +``` + +**Key differences from current JS code preserved:** +- **UID matching** (`cross-impact.ts:142-145`): Cypher checks `symbolUid IN $localUids` +- **Ref fallback** (`cross-impact.ts:146`, `linkMatchesRefs` at line 58-71): Cypher checks `filePath + '::' + symbolName IN $localRefs` when `symbolUid NOT IN $localUids`. This **preserves exact current semantics**: the JS code does `const refMatch = !uidMatch && linkMatchesRefs(...)` where `!uidMatch` means "UID didn't match the affected set" (regardless of whether UID is empty or stale). The Cypher `NOT ... IN $localUids` is equivalent. +- **Direction-dependent fan-out** (`cross-impact.ts:160-161`): Upstream fans out to `consumer.repo`, downstream fans out to `provider.repo` +- **Subgroup filter** (`cross-impact.ts:163`): Applied to the fan-out side, not the source side +- **Subgroup normalization**: `$subgroup` is normalized by the caller before passing to Cypher: `subgroup?.trim().replace(/\/+$/, '') || null`. This matches `inSubgroup()` from `cross-impact.ts:73-77` which does `subgroup.replace(/\/+$/, '')`. The Cypher `starts_with` check also needs the `=` case: `target.repo = $subgroup OR starts_with(target.repo, $subgroup + '/')` +- **Fan-out side info**: Query returns `fanOutFilePath` and `fanOutSymbolName` in addition to `fanOutUid` to support name-based fallback when UID is empty + +### Fan-Out with Empty symbolUid (gRPC contracts) + +The current `crossImpactFn` in `service.ts:189-199` uses `impactByUid()` which requires a valid UID (`MATCH (n) WHERE n.id = $uid`). gRPC contracts have `symbolUid: ''`, so `impactByUid('')` returns null — the fan-out silently fails. + +**Solution:** Extend `crossImpactFn` to fall back to name-based impact when UID is empty: + +```typescript +crossImpactFn: async (targetGroupPath: string, uid: string, d: string, hint?: { filePath: string; symbolName: string }) => { + const registryName = config.repos[targetGroupPath]; + if (!registryName) return null; + try { + const repoObj = await this.port.resolveRepo(registryName); + // If UID is available, use it (existing path) + if (uid) { + return this.port.impactByUid(repoObj.id, uid, d, impactOpts); + } + // Fallback: search by symbol name in the target repo. + // impact() resolves by name with priority ordering (Class > Interface > Function > ...), + // see local-backend.ts:1896. It does NOT support filePath scoping — if two symbols + // share the same name, the highest-priority label wins. This is a known limitation + // for gRPC fan-out: if "UserService" exists as both a Class and a Function in the + // target repo, the Class is chosen. In practice, gRPC service implementations are + // typically unique names within a repo, so this is acceptable. + if (hint?.symbolName) { + const result = await this.port.impact(repoObj, { + target: hint.symbolName, + direction: d as 'upstream' | 'downstream', + ...impactOpts, + }); + // impact() returns { error: ... } on not-found instead of null. + // Must check for error to avoid counting failures as successful fan-out + // (runGroupImpact truthy-checks the result at cross-impact.ts:176). + if (result && typeof result === 'object' && 'error' in result) return null; + return result; + } + return null; + } catch { + return null; + } +}, +``` + +The `hint` parameter carries `fanOutFilePath` and `fanOutSymbolName` from the Cypher query result. The caller (`runGroupImpact`) passes it when `fanOutUid` is empty: + +```typescript +// In cross-impact.ts Phase 2 loop: +const result = await opts.crossImpactFn( + row.fanOutRepo, + row.fanOutUid, + opts.direction, + row.fanOutUid ? undefined : { filePath: row.fanOutFilePath, symbolName: row.fanOutSymbolName }, +); +``` + +This ensures gRPC cross-links actually trigger remote impact analysis via name-based search, not silent null returns. + +### Future: Multi-Hop Traversal (crossDepth > 1) + +With bridge.lbug, multi-hop becomes a recursive Cypher query. Out of scope for this PR but the schema supports it. + +### Backward Compatibility + +**Unified fallback function:** All consumers (CLI, service, MCP) use a single `openBridgeOrFallback(groupDir)` helper: + +```typescript +async function openBridgeOrFallback(groupDir: string): Promise< + { type: 'bridge'; handle: BridgeHandle; meta: BridgeMeta } + | { type: 'json'; registry: LegacyContractRegistry; deprecationWarning: string } + | { type: 'none' } +> { + // 1. Try bridge.lbug + const handle = await openBridgeDbReadOnly(groupDir); + if (handle) { + const meta = await readBridgeMeta(groupDir); + return { type: 'bridge', handle, meta }; + } + // 2. Fallback to contracts.json (with deprecation warning) + const registry = await readContractRegistryJson(groupDir); + if (registry) { + // Return deprecation info in result — caller decides how to surface it. + // CLI prints warning; MCP includes it in response metadata (NOT console.warn, + // which would corrupt JSON-RPC protocol stream). + return { type: 'json', registry, deprecationWarning: 'contracts.json is deprecated. Run "gitnexus group sync " to migrate to bridge.lbug.' }; + } + // 3. Nothing found + return { type: 'none' }; +} +``` + +**Edge cases** (consistent behavior): +- Group has `contracts.json` but no `bridge.lbug` → fallback to JSON with deprecation warning +- Group has both → bridge.lbug wins (tried first), JSON ignored +- Group has corrupted `bridge.lbug` → `openBridgeDbReadOnly()` returns null → fallback to JSON if available, otherwise error +- Group has corrupted `bridge.lbug` + no JSON → error: "Run group_sync first" +- No `contracts.json` and no `bridge.lbug` → error: "Run group_sync first" + +**Key principle:** Corrupted bridge is NOT a hard error — it falls through to JSON like any "missing" bridge. Only when no data source is available does the user see an error. + +## Component 2: gRPC Canonical ID — Proto-Aware Extraction + +### Problem + +The gRPC extractor generates two incompatible contract ID formats: + +| Source | Format | Example | +|--------|--------|---------| +| `.proto` file | `grpc::package.Service/Method` | `grpc::com.example.UserService/GetUser` | +| Go `NewXClient()` | `grpc::ServiceName/*` | `grpc::UserService/*` | +| Java `ImplBase` | `grpc::ServiceName/*` | `grpc::UserService/*` | +| Python `Stub()` | `grpc::ServiceName/*` | `grpc::UserService/*` | +| TS `@GrpcMethod()` | `grpc::Service/Method` | `grpc::UserService/GetUser` | + +After normalization, `grpc::userservice/*` never matches `grpc::com.example.userservice/GetUser`. + +### Solution: Proto Map + +**New function** in `grpc-extractor.ts`: + +```typescript +interface ProtoServiceInfo { + package: string; // "com.example" (empty string if no package declaration) + serviceName: string; // "UserService" + methods: string[]; // ["GetUser", "ListUsers", ...] + protoPath: string; // "proto/user.proto" (for disambiguation) +} + +/** Scan .proto files in repo, build serviceName → package+methods map. */ +export async function buildProtoMap(repoPath: string): Promise>; +``` + +**Implementation:** +1. Glob `**/*.proto` in repoPath (reuse existing .proto scanning logic from lines 80-155) +2. Parse `package`, `service`, `rpc` declarations (regex-based, same as current .proto parsing) +3. If `.proto` has no `package` declaration, `package` is empty string `''` — `contractId` becomes `grpc::ServiceName/Method` (no dot prefix) +4. Build `Map` — key is bare service name (e.g. "UserService"), value is array (handles conflicts where two .proto files define the same service name with different packages) + +**Modified extraction flow:** + +``` +GrpcExtractor.extract(executor, repoPath, handle) + ├─ protoMap = buildProtoMap(repoPath) // NEW: build once per repo + ├─ extractFromProtoFiles(executor, repoPath) // unchanged — already full IDs + ├─ extractFromGoSource(executor, protoMap) // CHANGED: resolve via protoMap + ├─ extractFromJavaSource(executor, protoMap) // CHANGED + ├─ extractFromPythonSource(executor, protoMap) // CHANGED + ├─ extractFromTsSource(executor, protoMap) // CHANGED: resolve package via protoMap + └─ dedupe() // unchanged (see below) +``` + +### symbolUid for gRPC Contracts + +Currently, all gRPC contracts have `symbolUid: ''` (see `grpc-extractor.ts:70`). This is by design — the gRPC extractor doesn't query the graph for symbol UIDs because the contract represents a network boundary, not a code-level symbol. + +**This means `impactByUid` fan-out won't work for gRPC contracts.** Two levels of fallback are needed: + +1. **Bridge Cypher queries** (finding cross-links): Use ref fallback — `filePath + '::' + symbolName IN $localRefs` when UID is empty (see Cross-Impact Cypher Queries above). +2. **Remote fan-out** (`crossImpactFn`): Current `impactByUid()` in `service.ts:189-199` requires a valid UID (`MATCH (n) WHERE n.id = $uid`). With empty UID, it returns null — fan-out silently fails. **This must be extended** with a name-based fallback (see "Fan-Out with Empty symbolUid" in Cross-Impact section below). + +### No Dedup Between Proto and Source Contracts + +Proto-file contracts and source-resolved contracts are **both kept as separate Contract nodes** in bridge.lbug, even when they share the same `contractId`. This is correct because: + +1. They have different `filePath` values (e.g., `proto/user.proto` vs `src/server.go`) +2. They have different `symbolRef` — the proto entry points to the `.proto` definition, the source entry points to the actual Go/Java/Python code +3. During cross-impact analysis, developers need to see the **source code** file that implements/calls the service, not the `.proto` definition file +4. ContractLink matching works by `contractId`, not by Contract node `id` — both nodes participate in the same cross-links + +The existing `dedupe()` key `contractId|role|filePath` already handles this correctly: proto (`filePath: "proto/user.proto"`) and source (`filePath: "src/server.go"`) have different keys and both survive. + +**What dedupe still prevents:** True duplicates — e.g., if the same Go file is scanned twice, or if two different regex patterns match the same `RegisterXServer()` call. + +### Proto Map Disambiguation + +When multiple .proto files define the same service name with different packages, disambiguation uses **directory proximity** heuristic (not import path parsing, since Go/Java/Python scanners don't track imports): + +```typescript +function resolveProtoConflict( + serviceName: string, + sourceFilePath: string, + candidates: ProtoServiceInfo[], +): ProtoServiceInfo | null { + if (candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + // Score by directory proximity: shared path prefix length + const sourceDir = path.dirname(sourceFilePath); + let best = candidates[0]; + let bestScore = 0; + for (const c of candidates) { + const protoDir = path.dirname(c.protoPath); + const shared = commonPrefixLength(sourceDir, protoDir); + if (shared > bestScore) { + bestScore = shared; + best = c; + } + } + return best; +} +``` + +### Per-Scanner Resolution: Service-Level vs Method-Level Contracts + +**Design decision:** When a source scanner (Go/Java/Python) resolves via proto map, it generates a **single service-level contract** with a synthesized `contractId`, NOT one contract per method. This avoids false-positive explosion. + +**Rationale:** `RegisterUserServiceServer()` or `NewUserServiceClient()` is evidence that the code *uses the service* — not evidence that it calls *every method*. Expanding to per-method contracts with high confidence would be misleading. + +**Provider resolution (example: Go):** + +```typescript +// Before: +contracts.push({ contractId: serviceOnlyContractId('UserService'), confidence: 0.8 }); +// → grpc::UserService/* + +// After: +const candidates = protoMap.get('UserService'); +const proto = resolveProtoConflict('UserService', sourceFilePath, candidates ?? []); +if (proto) { + // Single service-level contract with canonical package prefix + contracts.push({ + contractId: serviceContractId(proto.package, proto.serviceName), + // → grpc::com.example.UserService/* + confidence: 0.8, // unchanged — still service-level evidence + filePath: sourceFilePath, + }); +} else { + contracts.push({ + contractId: serviceOnlyContractId('UserService'), + confidence: 0.65, // reduced — unresolved, no package + filePath: sourceFilePath, + }); +} +``` + +**New helper:** +```typescript +function serviceContractId(pkg: string, serviceName: string): string { + const prefix = pkg ? `${pkg}.${serviceName}` : serviceName; + return `grpc::${prefix}/*`; +} +``` + +This produces `grpc::com.example.UserService/*` — a wildcard with the correct package prefix. It will match `.proto`-extracted `grpc::com.example.UserService/GetUser` via wildcard matching (see below). + +**Consumer resolution (example: Go consumer):** + +```typescript +const candidates = protoMap.get('UserService'); +const proto = resolveProtoConflict('UserService', sourceFilePath, candidates ?? []); +if (proto) { + contracts.push({ + contractId: serviceContractId(proto.package, proto.serviceName), + confidence: 0.75, // proto-resolved consumer + role: 'consumer', + filePath: sourceFilePath, + }); +} else { + contracts.push({ + contractId: serviceOnlyContractId('UserService'), + confidence: 0.55, // reduced — unresolved consumer + role: 'consumer', + filePath: sourceFilePath, + }); +} +``` + +**TS `@GrpcMethod` resolution:** TS already has method-level info — keep producing per-method contracts: + +```typescript +const candidates = protoMap.get(serviceName); +const proto = resolveProtoConflict(serviceName, sourceFilePath, candidates ?? []); +const pkg = proto?.package ?? ''; +const cid = contractId(pkg, serviceName, methodName); +// → grpc::com.example.UserService/GetUser (per-method, with package) +``` + +Four source scanners (Go, Java, Python, TS) receive `protoMap` and use `resolveProtoConflict()`. The `.proto` extraction is unchanged — it already produces full canonical IDs. + +### Confidence Adjustments + +| Scenario | Before | After | Rationale | +|----------|--------|-------|-----------| +| .proto rpc (provider) | 0.85 | 0.85 | Unchanged — gold standard, per-method | +| Go/Java/Python register (provider), proto-resolved | 0.8 | 0.8 | Service-level with package prefix | +| Go/Java/Python register (provider), no proto | 0.8 | 0.65 | Reduced — wildcard, no package | +| Go/Java/Python client (consumer), proto-resolved | 0.7 | 0.75 | Slight boost — canonical package known | +| Go/Java/Python client (consumer), no proto | 0.7 | 0.55 | Reduced — wildcard, no package | +| TS `@GrpcMethod`, proto-resolved | 0.8 | 0.8 | Per-method with package from proto | +| TS `@GrpcMethod`, no proto | 0.8 | 0.8 | Per-method, no package — unchanged | + +### Matching: Wildcard Fallback and `matchType` + +For `grpc::*/*` contracts (service-level wildcards), add wildcard matching as a **separate pass** after `runExactMatch()`. + +**Critical: `runExactMatch` must exclude gRPC wildcard contracts.** If both consumer and provider have `grpc::com.example.UserService/*`, they would match as `exact` with `confidence: 1.0` — false positive. Wildcard-to-wildcard and wildcard-to-method matching is handled exclusively by `runWildcardMatch()`. + +`runExactMatch` is modified to **skip gRPC wildcard contracts** (contracts where `contractId` starts with `grpc::` AND ends with `/*`). These contracts are passed through to `unmatched` and handled by the wildcard pass. HTTP wildcard contracts (`http::*::/path`) are NOT affected — they don't end with `/*` and continue to use existing `findMatchingKeys` logic. + +**New and modified functions** in `matching.ts`: + +```typescript +/** Build a normalized contractId → contracts index. Exported for reuse by wildcard pass. */ +export function buildProviderIndex( + contracts: StoredContract[], +): Map; + +/** + * Updated signature: accepts optional pre-built index. + * Skips gRPC wildcard contracts (contractId starting with "grpc::" and ending with "/*") + * — these appear in `unmatched` for the wildcard pass. + */ +export function runExactMatch( + contracts: StoredContract[], + providerIndex?: Map, +): MatchResult; + +interface WildcardMatchResult { + matched: CrossLink[]; + remaining: StoredContract[]; +} + +export function runWildcardMatch( + unmatched: StoredContract[], + providerIndex: Map, +): WildcardMatchResult; +``` + +`buildProviderIndex()` is extracted from the existing private logic inside `runExactMatch()` and exported. **Keys in the returned Map are `normalizeContractId(contract.contractId)`** — i.e., lowercased package parts for gRPC. This is critical for case-insensitive matching in `runWildcardMatch()`. The index includes gRPC wildcard providers (they won't match in exact pass but `runWildcardMatch` explicitly skips them via `key.endsWith('/*')` check). `runExactMatch()` is updated to accept an optional pre-built index. + +**Implementation:** + +1. Filter `unmatched` for consumers with `contractId` ending in `/*` +2. For each wildcard consumer, extract the bare service name: + ```typescript + // "grpc::com.example.userservice/*" → "com.example.userservice" + // "grpc::userservice/*" → "userservice" + const normalizedWildcard = normalizeContractId(consumer.contractId); + const fqServiceFromConsumer = normalizedWildcard.slice( + normalizedWildcard.indexOf('::') + 2, -2); // strip "grpc::" and "/*" + ``` +3. Search `providerIndex` for **non-wildcard** providers whose FQ service matches: + ```typescript + // Only match providers that have actual method-level IDs (not wildcards themselves) + for (const [key, providers] of providerIndex) { + if (!key.startsWith('grpc::') || key.endsWith('/*')) continue; // skip non-grpc and wildcards + const afterPrefix = key.slice(6); // strip "grpc::" + const slashIdx = afterPrefix.indexOf('/'); + if (slashIdx < 0) continue; + const fqServiceFromProvider = afterPrefix.slice(0, slashIdx); + // Exact match on FQ service, or bare-name match if consumer has no package + if (fqServiceFromProvider === fqServiceFromConsumer + || (!fqServiceFromConsumer.includes('.') && fqServiceFromProvider.endsWith('.' + fqServiceFromConsumer))) { + // Match found + } + } + ``` +4. Create CrossLink with `matchType: 'wildcard'`, `contractId: consumer.contractId` (the wildcard ID — consistent with `runExactMatch` which always stores `consumer.contractId`) +5. **Confidence:** `min(provider.confidence, consumer.confidence)` — no additional penalty. The wildcard penalty is already baked into the consumer's reduced confidence (0.55-0.75 depending on proto resolution). Applying an additional 0.5× multiplier would push values below `minConfidence` threshold. + +**Why no 0.5× multiplier:** With consumer confidence 0.55 (no proto) and provider 0.85, `0.5 × min(0.55, 0.85) = 0.275` — well below the default `minConfidence: 0.5`. The wildcard feature would be dead by default. Instead, the penalty is in the source confidence itself (0.55 vs 0.7 baseline), which keeps wildcard matches viable at default thresholds. + +**Sync integration:** + +```typescript +// In syncGroup(): +const providerIndex = buildProviderIndex(autoContracts); +const { matched: exactLinks, unmatched } = runExactMatch(autoContracts, providerIndex); +const { matched: wildcardLinks, remaining } = runWildcardMatch(unmatched, providerIndex); +const crossLinks = [...manifestResult.crossLinks, ...exactLinks, ...wildcardLinks]; +``` + +**Note:** `runExactMatch` and `runWildcardMatch` operate on `autoContracts` only — manifest contracts are already matched by `ManifestExtractor` and added separately. This prevents duplicate links. + +## Testing Strategy + +### Bridge.lbug Tests + +**Unit tests** (`test/unit/group/bridge-db.test.ts`): +- Schema creation (idempotent, re-run safe) +- Write + read contracts round-trip (verify all fields including filePath) +- Write + read cross-links round-trip (verify fromRepo/toRepo denormalization) +- Multiple contracts with same contractId but different filePath → both stored +- RepoSnapshot persistence (keyed by repo path within group) +- meta.json persistence (version from BRIDGE_SCHEMA_VERSION, generatedAt, missingRepos) +- missingRepos survives write + read cycle via meta.json +- Write-to-temp-then-rename: old data fully replaced, new data correct +- Read-only mode rejects writes (error thrown) +- Failed insert: bridge.lbug untouched, bridge.lbug.tmp cleaned up +- Atomic rename: .bak created during swap, removed after success +- Windows EBUSY/EPERM/EACCES retry: retryRename handles concurrent read-only handles +- Full SHA-256 PK: verify no truncation (64 hex chars) +- Corrupted bridge.lbug: `openBridgeDbReadOnly()` returns null +- `bridgeExists()`: true when DB opens, false when missing or corrupt +- Bridge.lbug is a file, not a directory + +**Integration tests** (`test/integration/group/bridge-sync.test.ts`): +- `syncGroup()` creates bridge.lbug with correct data +- `groupContracts()` reads from bridge.lbug (filtered by type, repo) +- `groupContracts()` with `--unmatched` flag after wildcard canonicalization +- `groupImpact()` traverses bridge.lbug cross-links (upstream direction) +- `groupImpact()` traverses bridge.lbug cross-links (downstream direction) +- `groupStatus()` returns missingRepos from meta.json and repoSnapshots from Cypher query +- `groupStatus()` contractsStale check uses RepoSnapshot nodes from bridge.lbug +- Backward compat: group with only contracts.json → fallback with deprecation warning +- Backward compat: group with both contracts.json and bridge.lbug → bridge.lbug wins +- Backward compat: corrupted bridge.lbug + contracts.json exists → fallback to JSON +- Backward compat: corrupted bridge.lbug + no JSON → error "Run group_sync first" +- Backward compat: no contracts.json and no bridge.lbug → error "Run group_sync first" +- Re-sync overwrites previous bridge.lbug data + +### gRPC Canonical ID Tests + +**Unit tests** (`test/unit/group/grpc-extractor.test.ts` — extend existing): +- `buildProtoMap()`: single proto, multiple protos, no protos in repo +- `buildProtoMap()`: proto without `package` declaration → `package: ''`, contractId = `grpc::ServiceName/Method` +- `buildProtoMap()`: conflicting service names (same name, different packages) → array with both entries +- `resolveProtoConflict()`: single candidate → returns it +- `resolveProtoConflict()`: multiple candidates → picks closest by directory +- `resolveProtoConflict()`: no candidates → returns null +- Proto-resolved extraction: Go provider + .proto → `grpc::pkg.Service/*` (service-level, NOT per-method) +- Proto-resolved extraction: Java consumer + .proto → `grpc::pkg.Service/*` (service-level) +- Proto-resolved extraction: TS `@GrpcMethod` + .proto → `grpc::pkg.Service/Method` (per-method, with package) +- Fallback: no .proto → `grpc::ServiceName/*` with reduced confidence (0.65 provider, 0.55 consumer) +- No dedup between proto and source: both kept as separate entries with different filePaths +- symbolUid is empty for all gRPC contracts (by design) + +**Unit tests** (`test/unit/group/matching.test.ts` — extend existing): +- `runWildcardMatch()`: `grpc::com.example.userservice/*` matches `grpc::com.example.userservice/GetUser` (FQ match) +- `runWildcardMatch()`: `grpc::userservice/*` matches `grpc::com.example.userservice/GetUser` (bare-name match) +- `runWildcardMatch()`: does NOT match `grpc::com.example.otherservice/GetUser` +- `runWildcardMatch()`: does NOT match non-grpc contracts +- `runWildcardMatch()`: does NOT match wildcard providers (`grpc::Service/*` consumer vs `grpc::Service/*` provider → skip) +- `runWildcardMatch()`: confidence = min(provider, consumer) — no 0.5× multiplier +- `runWildcardMatch()`: contractId on link = consumer's contractId (consistent with runExactMatch) +- `runWildcardMatch()`: matchType = 'wildcard' +- `runExactMatch()`: skips gRPC `/*` contracts (no wildcard-wildcard exact match); HTTP wildcards unaffected +- `runExactMatch()`: wildcard contracts appear in unmatched output +- Exact match runs first; wildcard only processes remaining unmatched +- Manifest contracts not passed to exact/wildcard match (no duplicate links) + +### Cross-Impact Tests + +**Unit tests** (`test/unit/group/cross-impact.test.ts` — extend existing): +- Phase 2 upstream: query matches on provider side, fans out to consumer repo +- Phase 2 downstream: query matches on consumer side, fans out to provider repo +- UID matching works against bridge DB +- Ref fallback matching works when symbolUid is empty (gRPC contracts) +- Ref fallback matching works when symbolUid is non-empty but stale (not in localUids) — preserves current `!uidMatch` semantics +- Subgroup filtering applied to fan-out side, not source side +- Confidence filtering via Cypher WHERE clause +- Direction-specific Cypher query equivalence with current JS loop +- Fan-out with empty symbolUid: crossImpactFn falls back to name-based search via hint +- Fan-out with valid symbolUid: crossImpactFn uses impactByUid (existing path) +- Subgroup normalization: trailing `/` stripped before Cypher, `team/a` matches `team/a` and `team/a/sub` +- Deprecation warning returned in result (not console.warn) when JSON fallback used + +## Files Changed + +### New Files +| File | Purpose | +|------|---------| +| `src/core/group/bridge-db.ts` | Bridge LadybugDB lifecycle, schema, read/write, atomic rename | +| `src/core/group/bridge-schema.ts` | Schema DDL constants + BRIDGE_SCHEMA_VERSION for bridge.lbug | +| `test/unit/group/bridge-db.test.ts` | Bridge DB unit tests | +| `test/integration/group/bridge-sync.test.ts` | Bridge DB integration tests | + +### Modified Files +| File | Changes | +|------|---------| +| `src/core/group/sync.ts` | Replace `writeContractRegistry()` with `writeBridge()`; add `runWildcardMatch()` pass on `autoContracts` only (not manifest) | +| `src/core/group/service.ts` | Use `openBridgeOrFallback()`; extend `crossImpactFn` with `hint` param for name-based fallback when UID empty | +| `src/core/group/cross-impact.ts` | Accept bridge executor; two direction-dependent Cypher queries with UID+ref fallback | +| `src/core/group/storage.ts` | Remove `writeContractRegistry()`, `readContractRegistry()`, `CONTRACTS_FILE`; keep `readContractRegistryJson()` as private fallback; add `openBridgeOrFallback()` (imports `openBridgeDbReadOnly`, `closeBridgeDb`, `readBridgeMeta` from `bridge-db.ts`) | +| `src/core/group/types.ts` | Add `BridgeHandle`, `BridgeMeta` types; rename `ContractRegistry` → `LegacyContractRegistry` (@deprecated) | +| `src/core/group/extractors/grpc-extractor.ts` | Add `buildProtoMap()`, `resolveProtoConflict()`, `serviceContractId()`; modify 4 source scanners; keep service-level contracts (no per-method expansion for Go/Java/Python) | +| `src/core/group/matching.ts` | Export `buildProviderIndex()` (normalized keys); `runExactMatch` skips gRPC `/*` contracts; add `runWildcardMatch()` excluding wildcard providers; add `'wildcard'` to MatchType | +| `src/cli/group.ts` | Update `sync`, `impact`, `status` to use `openBridgeOrFallback()` | +| `src/mcp/local/local-backend.ts` | Update `groupImpact()`, `groupContracts()` to use `openBridgeOrFallback()` | +| `test/unit/group/grpc-extractor.test.ts` | Add proto map, service-level canonical ID, no-package proto, TS per-method resolution tests | +| `test/unit/group/matching.test.ts` | Add `runWildcardMatch()` tests: wildcard-provider exclusion, no multiplier, matchType checks | +| `test/unit/group/cross-impact.test.ts` | Direction-specific Cypher equivalence, ref fallback for empty UID | +| `test/unit/group/sync.test.ts` | Update for bridge.lbug writes + wildcard on autoContracts only | +| `test/unit/group/service.test.ts` | Update for bridge.lbug reads + openBridgeOrFallback | + +### Deleted / Renamed +| File/Symbol | Reason | +|-------------|--------| +| `storage.ts: writeContractRegistry()` | Replaced by bridge-db.ts | +| `storage.ts: readContractRegistry()` | Replaced by bridge-db.ts (JSON fallback kept as private `readContractRegistryJson()`) | +| `storage.ts: CONTRACTS_FILE` | No longer used | +| `types.ts: ContractRegistry` | **Renamed** to `LegacyContractRegistry` (@deprecated); still used by JSON fallback path | +| `contracts.json` files | No longer created by `group sync` | + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| LadybugDB write perf during sync | Bulk COPY (same pattern as lbug-adapter.ts), not individual inserts | +| Bridge DB data loss on failed sync | Write-to-temp-then-rename: old file untouched until new file fully written; .bak recovery hint | +| Bridge DB lock during concurrent reads | Write-to-temp eliminates lock contention; retryRename for Windows EBUSY/EPERM/EACCES | +| Bridge DB corruption | Falls through to JSON fallback (not a hard error); only errors if no data source available | +| Proto map parse errors on malformed .proto | Regex-based parsing with try/catch, skip unparseable files | +| Proto map conflicts (same service name) | Directory proximity heuristic; no import path parsing needed | +| Proto without package declaration | `package: ''` → contractId = `grpc::ServiceName/Method` (tested) | +| Backward compat: existing groups have contracts.json | `openBridgeOrFallback()`: bridge.lbug first, then JSON, then error | +| ContractLink JOIN perf for repo filtering | Denormalized `fromRepo`/`toRepo` on relation avoid JOINs | +| Wildcard false positives (general) | No per-method expansion for service-level evidence; confidence penalty in source extraction | +| Wildcard bare-name cross-package false positive | `grpc::userservice/*` can match `grpc::other.userservice/GetUser` (different package). Accepted risk: bare-name consumers (no proto) already have reduced confidence (0.55); false positives surface as low-confidence cross-links. Users can raise `minConfidence` to filter. A future improvement could require FQ match only when consumer has package prefix. | +| gRPC symbolUid empty | Ref fallback in Cypher queries (filePath+symbolName match); name-based fan-out with error-object guard | +| missingRepos data loss | Stored in meta.json alongside bridge.lbug | + +## Implementation Notes + +Issues identified during spec review that are best addressed during TDD implementation rather than in the design doc: + +1. **Swap partial failure recovery (#3):** If `temp→final` rename fails after `final→bak`, `bridge.lbug` is missing. Implementation should check for `.bak` in `openBridgeDbReadOnly()` and auto-restore if `bridge.lbug` is absent. +2. **`meta.json` read failure (#4):** `readBridgeMeta()` should return sensible defaults (`{ version: 0, generatedAt: '', missingRepos: [] }`) if file is missing/corrupt, not throw. `openBridgeOrFallback` should handle this gracefully. +3. **Proto+source dedup and matching identity (#5):** Two Contract nodes with same `(repo, contractId)` but different `filePath` will both mark as matched via `${repo}::${contractId}`. This means both proto and source entries mark as matched simultaneously — correct for `unmatched` filtering. But `runExactMatch` may create duplicate CrossLinks (one per node). Implementation should dedup CrossLinks by `(from.repo, to.repo, contractId)`. +4. **gRPC symbolName quality (#6):** Extractors store technical names (`RegisterUserServiceServer`, `NewUserServiceClient`). The name-based fallback may not find the actual symbol in the target repo. Implementation should extract the service name from these patterns (strip `Register`/`New`/`Server`/`Client` prefixes) before passing as `hint.symbolName`. +5. **DDL syntax compatibility (#11):** Schema DDL in this spec uses `IF NOT EXISTS` and inline `PRIMARY KEY`. Verify against actual LadybugDB/DuckDB version used in the project. If incompatible, adapt to match `schema.ts` conventions (separate PRIMARY KEY clause, no IF NOT EXISTS with try/catch wrapper). +6. **`fromRepo`/`toRepo` denormalization (#9):** Current Cypher queries don't use them. Remove from schema if no concrete use case emerges during implementation. Keep if needed for future index-only scans. +7. **`no data source` UX per command (#12):** `groupStatus` returns empty status; `groupImpact`/`groupContracts` return error. Standardize during implementation: all commands that require data return `{ error: "No contract data. Run 'gitnexus group sync '." }`. +8. **Blast radius (#10):** `src/mcp/tools.ts` tool descriptions reference `contracts.json` — update text. Tests `storage.test.ts`, `types.test.ts`, `group-impact.test.ts` also need updates — add to implementation plan. +9. **Fallback ownership (#8):** `openBridgeOrFallback()` lives in `storage.ts`. `cross-impact.ts` does NOT do fallback — it receives either `bridgeQuery` or `registry` from `service.ts`. Service layer owns the fallback decision.